Compare commits
1 Commits
dev
...
243-produc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f38b8884ff |
@ -1,34 +1,30 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 2.10.0 -> 2.11.0
|
- Version change: 2.9.0 -> 2.10.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Expanded decision-first and operator-surface rules so operational,
|
- Expanded Operations / Run Observability Standard so OperationRun
|
||||||
governance, evidence, onboarding, review, and support-facing
|
start UX is shared-contract-owned instead of surface-owned
|
||||||
detail/status surfaces separate decision content, operator
|
- Expanded Governance review expectations for OperationRun-starting
|
||||||
diagnostics, and support/raw evidence
|
features, explicit queued-notification policy, and bounded
|
||||||
- Expanded review and enforcement expectations so specs, plans,
|
exceptions
|
||||||
tasks, and checklists must make audience modes, raw/support
|
|
||||||
gating, one dominant next action, and duplicate-truth prevention
|
|
||||||
explicit
|
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- Audience-Aware Decision Surfaces & Disclosure Ladder
|
- OperationRun Start UX Contract (OPS-UX-START-001): centralizes
|
||||||
(DECIDE-AUD-001): requires customer-readable default paths,
|
queued toast/link/event/message semantics, run/artifact deep links,
|
||||||
operator diagnostics as progressive disclosure, support/raw
|
queued DB-notification policy, and tenant/workspace-safe operation
|
||||||
evidence gating, one dominant next action, and no duplicate truth
|
URL resolution behind one shared OperationRun UX layer
|
||||||
across equal-priority cards
|
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- .specify/templates/spec-template.md: add audience-aware disclosure
|
- .specify/templates/spec-template.md: add OperationRun UX Impact
|
||||||
section + constitution prompts ✅
|
section + start-contract prompts ✅
|
||||||
- .specify/templates/plan-template.md: add audience/disclosure
|
- .specify/templates/plan-template.md: add OperationRun UX Impact
|
||||||
planning prompts + constitution checks ✅
|
planning section + constitution checks ✅
|
||||||
- .specify/templates/tasks-template.md: add decision/disclosure
|
- .specify/templates/tasks-template.md: add central start-UX reuse,
|
||||||
implementation + test tasks ✅
|
queued-notification policy, and exception tasks ✅
|
||||||
- .specify/templates/checklist-template.md: add disclosure, raw-gating,
|
- .specify/templates/checklist-template.md: add OperationRun start
|
||||||
one-primary-action, and duplicate-truth review checks ✅
|
UX review checks ✅
|
||||||
- docs/product/standards/README.md: refresh constitution index for
|
- docs/product/standards/README.md: refresh constitution index for
|
||||||
the new audience-aware disclosure contract ✅
|
the new ops-UX contract ✅
|
||||||
- Commands checked:
|
- Commands checked:
|
||||||
- N/A `.specify/templates/commands/*.md` directory is not present
|
- N/A `.specify/templates/commands/*.md` directory is not present
|
||||||
- Follow-up TODOs: None
|
- Follow-up TODOs: None
|
||||||
@ -593,114 +589,11 @@ ##### Review gate
|
|||||||
5. Is this a Primary Decision Surface, Secondary Context Surface, or
|
5. Is this a Primary Decision Surface, Secondary Context Surface, or
|
||||||
Tertiary Evidence / Diagnostics Surface?
|
Tertiary Evidence / Diagnostics Surface?
|
||||||
6. If it is primary, why can it not live inside an existing decision
|
6. If it is primary, why can it not live inside an existing decision
|
||||||
context?
|
context?
|
||||||
7. Does the navigation reflect a workflow or only storage structure?
|
7. Does the navigation reflect a workflow or only storage structure?
|
||||||
8. Does this reduce search, review, or click work?
|
8. Does this reduce search, review, or click work?
|
||||||
9. Does this make the product calmer and clearer instead of louder?
|
9. Does this make the product calmer and clearer instead of louder?
|
||||||
|
|
||||||
#### Audience-Aware Decision Surfaces & Disclosure Ladder (DECIDE-AUD-001)
|
|
||||||
|
|
||||||
Goal: every operational, governance, evidence, onboarding, review, and
|
|
||||||
support-facing detail or status surface MUST keep customer-readable
|
|
||||||
decision content, operator diagnostics, and support/raw evidence
|
|
||||||
intentionally separated while preserving full depth through progressive
|
|
||||||
disclosure.
|
|
||||||
|
|
||||||
##### Audience ladder is explicit
|
|
||||||
|
|
||||||
- In-scope detail and status surfaces MUST define their content using
|
|
||||||
this three-tier hierarchy when applicable:
|
|
||||||
- decision content
|
|
||||||
- operator diagnostics
|
|
||||||
- support / raw evidence
|
|
||||||
- Surfaces that are reachable by more than one audience class MUST
|
|
||||||
define their default-visible content for at least these layers when
|
|
||||||
applicable:
|
|
||||||
- customer / read-only default
|
|
||||||
- operator / MSP diagnostics
|
|
||||||
- platform / support raw evidence
|
|
||||||
- The surface contract MUST state which capabilities unlock each deeper
|
|
||||||
layer.
|
|
||||||
- Support/raw evidence MUST NOT become the default first-read
|
|
||||||
experience on customer-readable or ordinary operator-facing
|
|
||||||
surfaces.
|
|
||||||
|
|
||||||
##### Customer-readable default path
|
|
||||||
|
|
||||||
- The default reading path for customer/read-only users MUST optimize
|
|
||||||
for status, reason, impact, one dominant next action, and a short
|
|
||||||
result or artifact summary.
|
|
||||||
- Internal lifecycle wording, debug semantics, implementation field
|
|
||||||
names, raw payload fragments, and support-oriented context MUST NOT
|
|
||||||
appear in the default customer-readable path unless they are the only
|
|
||||||
way to understand the first decision.
|
|
||||||
- Default-visible customer/read-only content is responsible for status,
|
|
||||||
reason, impact, the dominant next action, and a concise supporting
|
|
||||||
summary only.
|
|
||||||
|
|
||||||
##### Diagnostics are secondary by default
|
|
||||||
|
|
||||||
- Diagnostics such as lifecycle, timings, verification detail, drift
|
|
||||||
detail, permission detail, provider summaries, or related-operation
|
|
||||||
context MUST be lower-priority than the decision surface and MUST be
|
|
||||||
collapsed, tabbed, grouped, or otherwise progressively disclosed when
|
|
||||||
the first decision does not require them.
|
|
||||||
- Authorized operators MAY expand diagnostics, but diagnostics MUST NOT
|
|
||||||
visually compete with the primary decision block.
|
|
||||||
- Where no support/raw tier is exposed, diagnostics still remain below
|
|
||||||
the decision tier and MUST NOT restate the same decision summary at
|
|
||||||
equal weight.
|
|
||||||
|
|
||||||
##### Raw/support evidence is gated
|
|
||||||
|
|
||||||
- Raw/support evidence such as JSON, raw context payloads,
|
|
||||||
fingerprints, internal reason ownership, platform reason families,
|
|
||||||
monitoring detail, viewer context, or copy/show-raw actions MUST NOT
|
|
||||||
appear in the default decision path.
|
|
||||||
- These details MUST live behind explicit reveal affordances and MUST
|
|
||||||
be capability-gated wherever the audience model distinguishes support
|
|
||||||
or platform users from ordinary operators.
|
|
||||||
- Capability-gated support/raw disclosure MUST fail closed when the
|
|
||||||
actor lacks the required scope or capability.
|
|
||||||
|
|
||||||
##### One dominant next action
|
|
||||||
|
|
||||||
- A decision surface MUST expose exactly one dominant next action in
|
|
||||||
the default-visible region.
|
|
||||||
- Optional secondary actions MAY exist, but they MUST NOT compete with
|
|
||||||
the primary remediation or decision action in prominence.
|
|
||||||
- Contextual navigation such as opening a related run, tenant, report,
|
|
||||||
or technical detail remains secondary.
|
|
||||||
|
|
||||||
##### No duplicate truth across equal-priority cards
|
|
||||||
|
|
||||||
- The same blocker, reason, or next action MUST NOT be repeated across
|
|
||||||
multiple equal-priority cards, sections, or summary blocks on the
|
|
||||||
same default-visible surface.
|
|
||||||
- Supporting evidence MAY restate the underlying proof, but the
|
|
||||||
dominant decision message appears once and diagnostics elaborate
|
|
||||||
beneath it.
|
|
||||||
|
|
||||||
##### Required tests
|
|
||||||
|
|
||||||
- New or materially changed customer/operator-facing detail surfaces
|
|
||||||
MUST include focused tests proving:
|
|
||||||
- default-visible content shows status, reason, impact, and next
|
|
||||||
action,
|
|
||||||
- exactly one dominant next action is primary,
|
|
||||||
- diagnostics are secondary or collapsed,
|
|
||||||
- raw/support evidence is not default-visible,
|
|
||||||
- support/raw sections are capability-gated where applicable,
|
|
||||||
- and duplicate visible decision summaries are absent.
|
|
||||||
|
|
||||||
##### Stored evidence wins over fallback diagnostics
|
|
||||||
|
|
||||||
- When a stored verification or report artifact exists, fallback
|
|
||||||
technical diagnostics SHOULD demote behind supporting evidence or
|
|
||||||
technical details instead of remaining peer-level default content.
|
|
||||||
- Fallback diagnostics MAY become temporarily prominent only when the
|
|
||||||
higher-level artifact does not yet exist or is unavailable.
|
|
||||||
|
|
||||||
#### Surface Taxonomy (UI-SURF-001)
|
#### Surface Taxonomy (UI-SURF-001)
|
||||||
|
|
||||||
Every new admin surface MUST be assigned exactly one broad action-surface
|
Every new admin surface MUST be assigned exactly one broad action-surface
|
||||||
@ -1424,22 +1317,11 @@ #### Operator Surface Principles (OPSURF-001)
|
|||||||
- Diagnostic detail MAY exist, but it MUST be secondary and explicitly revealed.
|
- Diagnostic detail MAY exist, but it MUST be secondary and explicitly revealed.
|
||||||
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces rather than the primary content region.
|
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces rather than the primary content region.
|
||||||
- Operators MUST NOT need to parse raw payloads to understand current state or next action.
|
- Operators MUST NOT need to parse raw payloads to understand current state or next action.
|
||||||
- Detail/status surfaces MUST satisfy DECIDE-AUD-001: decision content
|
|
||||||
first, operator diagnostics second, support/raw evidence third.
|
|
||||||
|
|
||||||
Distinct truth dimensions
|
Distinct truth dimensions
|
||||||
- When the domain has execution outcome, data completeness, governance result, lifecycle or readiness state, operability truth, health truth, trust/confidence, or next action semantics, the surface MUST keep them explicit instead of collapsing them into one ambiguous status.
|
- When the domain has execution outcome, data completeness, governance result, lifecycle or readiness state, operability truth, health truth, trust/confidence, or next action semantics, the surface MUST keep them explicit instead of collapsing them into one ambiguous status.
|
||||||
- If multiple truth dimensions are summarized, the default-visible UI MUST label each dimension clearly.
|
- If multiple truth dimensions are summarized, the default-visible UI MUST label each dimension clearly.
|
||||||
|
|
||||||
Dominant next action and duplicate-truth control
|
|
||||||
- Default-visible decision content MUST include status, reason,
|
|
||||||
impact, and one dominant next action where those concepts exist.
|
|
||||||
- Secondary navigation or debug helpers MUST remain lower-priority
|
|
||||||
than the dominant decision action.
|
|
||||||
- The same blocker, reason, impact, or next action MUST NOT be
|
|
||||||
repeated across multiple default-visible cards, sections, tabs, or
|
|
||||||
summaries.
|
|
||||||
|
|
||||||
Explicit mutation scope
|
Explicit mutation scope
|
||||||
- Every state-changing action MUST communicate before execution whether it affects TenantPilot only, the Microsoft tenant, or simulation only.
|
- Every state-changing action MUST communicate before execution whether it affects TenantPilot only, the Microsoft tenant, or simulation only.
|
||||||
- Mutation scope MUST be understandable from nearby action copy, helper text, preview, or confirmation.
|
- Mutation scope MUST be understandable from nearby action copy, helper text, preview, or confirmation.
|
||||||
@ -1460,13 +1342,6 @@ #### Operator Surface Principles (OPSURF-001)
|
|||||||
Page contract requirement
|
Page contract requirement
|
||||||
- Every new or materially refactored operator-facing page MUST define the primary persona, surface type, primary operator question, default-visible information, diagnostics-only information, status dimensions used, mutation scope, primary actions, and dangerous actions.
|
- Every new or materially refactored operator-facing page MUST define the primary persona, surface type, primary operator question, default-visible information, diagnostics-only information, status dimensions used, mutation scope, primary actions, and dangerous actions.
|
||||||
- The page contract MUST live in the governing spec and stay in sync with implementation.
|
- The page contract MUST live in the governing spec and stay in sync with implementation.
|
||||||
- Where multiple audience classes share the page, the contract MUST
|
|
||||||
explicitly define the customer/read-only default path, operator
|
|
||||||
diagnostics path, support/raw-evidence path, and the capabilities
|
|
||||||
that unlock each layer.
|
|
||||||
- The page contract MUST also make the dominant next action,
|
|
||||||
duplicate-truth prevention, and raw/support gating explicit for
|
|
||||||
changed detail/status surfaces.
|
|
||||||
|
|
||||||
#### Spec Scope Fields (SCOPE-002)
|
#### Spec Scope Fields (SCOPE-002)
|
||||||
|
|
||||||
@ -1491,11 +1366,8 @@ #### Enforcement Model (UI-REVIEW-001)
|
|||||||
native, custom, or a shared detail family, what shared core vs host
|
native, custom, or a shared detail family, what shared core vs host
|
||||||
variation exists if relevant, which layer owns the relevant shell,
|
variation exists if relevant, which layer owns the relevant shell,
|
||||||
page, and detail truth, which requested/active/draft/inspect/
|
page, and detail truth, which requested/active/draft/inspect/
|
||||||
restorable roles exist, which audience ladder and disclosure
|
restorable roles exist, whether any fake-native or host-drift risk is
|
||||||
boundaries exist, what the dominant next action is, how raw/support
|
present, and whether an exception type is used.
|
||||||
evidence is gated, how duplicate truth is prevented, whether any
|
|
||||||
fake-native or host-drift risk is present, and whether an exception
|
|
||||||
type is used.
|
|
||||||
- Missing any of those answers makes the spec incomplete.
|
- Missing any of those answers makes the spec incomplete.
|
||||||
|
|
||||||
PR review requirements
|
PR review requirements
|
||||||
@ -1510,12 +1382,7 @@ #### Enforcement Model (UI-REVIEW-001)
|
|||||||
promoted into primary navigation without justification, one case
|
promoted into primary navigation without justification, one case
|
||||||
fragmented across multiple equal-rank pages, new automation that adds
|
fragmented across multiple equal-rank pages, new automation that adds
|
||||||
attention surfaces without reducing operator work, noisy default
|
attention surfaces without reducing operator work, noisy default
|
||||||
surfaces with no action/watch/reference hierarchy, duplicate visible
|
surfaces with no action/watch/reference hierarchy, `Filament Costume`,
|
||||||
blocker/reason/next-action summaries, customer/operator default paths
|
|
||||||
that expose raw JSON, fingerprints, reason ownership, platform reason
|
|
||||||
families, or monitoring detail, helper actions such as `Open
|
|
||||||
operation`, `Technical details`, or `Show JSON` competing with the
|
|
||||||
dominant decision action, `Filament Costume`,
|
|
||||||
`Blade Request UI`, `Hand-Rolled Simple Overview`, `Hidden Exception`,
|
`Blade Request UI`, `Hand-Rolled Simple Overview`, `Hidden Exception`,
|
||||||
`Host Drift`, `State Layer Collapse`, `Parallel Inspect Worlds`, or
|
`Host Drift`, `State Layer Collapse`, `Parallel Inspect Worlds`, or
|
||||||
undocumented exceptions without dedicated tests.
|
undocumented exceptions without dedicated tests.
|
||||||
@ -1527,15 +1394,11 @@ #### Enforcement Model (UI-REVIEW-001)
|
|||||||
presence of explicit Inspect on Queue / Review and History / Audit
|
presence of explicit Inspect on Queue / Review and History / Audit
|
||||||
surfaces, absence of empty `ActionGroup` or `BulkActionGroup`,
|
surfaces, absence of empty `ActionGroup` or `BulkActionGroup`,
|
||||||
correct placement of destructive actions, truthful scope signals,
|
correct placement of destructive actions, truthful scope signals,
|
||||||
stable canonical nouns across shells, presence of a single dominant
|
stable canonical nouns across shells, absence of fake-native primary
|
||||||
next action where surface metadata exposes one, absence of duplicate
|
controls where metadata says the surface is native, bounded shared
|
||||||
visible decision summaries, explicit raw/support gating or secondary
|
family contracts where metadata says a family is reused, explicit
|
||||||
placement where the surface serves multiple audience classes,
|
state ownership where specs or metadata expose it, and dedicated
|
||||||
absence of fake-native primary controls where metadata says the
|
tests for every approved exception.
|
||||||
surface is native, bounded shared family contracts where metadata
|
|
||||||
says a family is reused, explicit state ownership where specs or
|
|
||||||
metadata expose it, and dedicated tests for every approved
|
|
||||||
exception.
|
|
||||||
|
|
||||||
#### Immediate Retrofit Priorities
|
#### Immediate Retrofit Priorities
|
||||||
|
|
||||||
@ -1602,10 +1465,6 @@ #### Appendix A - One-page Condensed Constitution
|
|||||||
- Scope chips must be truthful.
|
- Scope chips must be truthful.
|
||||||
- Domain nouns are canonical and stable.
|
- Domain nouns are canonical and stable.
|
||||||
- Critical operational truth is default-visible.
|
- Critical operational truth is default-visible.
|
||||||
- Multi-audience detail/status surfaces keep customer-readable decision
|
|
||||||
content above operator diagnostics and support/raw evidence.
|
|
||||||
- One dominant next action stays visually primary.
|
|
||||||
- Duplicate visible decision truth is forbidden.
|
|
||||||
- Semantic truth dimensions are not collapsed into a generic status.
|
- Semantic truth dimensions are not collapsed into a generic status.
|
||||||
- Standard lists stay scanable.
|
- Standard lists stay scanable.
|
||||||
- Exceptions are catalogued, justified, and tested.
|
- Exceptions are catalogued, justified, and tested.
|
||||||
@ -1618,8 +1477,6 @@ #### Appendix B - Feature Review Checklist
|
|||||||
- The human-in-the-loop moment is explicit.
|
- The human-in-the-loop moment is explicit.
|
||||||
- Immediate-visible decision information is explicit.
|
- Immediate-visible decision information is explicit.
|
||||||
- On-demand evidence / diagnostics boundaries are explicit.
|
- On-demand evidence / diagnostics boundaries are explicit.
|
||||||
- Audience-aware default visibility and raw-evidence boundaries are
|
|
||||||
explicit where the page serves more than one audience class.
|
|
||||||
- Any new primary surface is justified against an existing decision
|
- Any new primary surface is justified against an existing decision
|
||||||
context.
|
context.
|
||||||
- Navigation reflects a workflow rather than storage structure.
|
- Navigation reflects a workflow rather than storage structure.
|
||||||
@ -1629,8 +1486,6 @@ #### Appendix B - Feature Review Checklist
|
|||||||
- Broad action-surface class is declared.
|
- Broad action-surface class is declared.
|
||||||
- Detailed surface type is declared.
|
- Detailed surface type is declared.
|
||||||
- The one most likely next operator action is explicit.
|
- The one most likely next operator action is explicit.
|
||||||
- One dominant next action stays primary.
|
|
||||||
- Duplicate visible decision truth is absent.
|
|
||||||
- The surface is classified correctly as native, custom, or shared
|
- The surface is classified correctly as native, custom, or shared
|
||||||
family.
|
family.
|
||||||
- Primary inspect/open model is defined.
|
- Primary inspect/open model is defined.
|
||||||
@ -1712,10 +1567,6 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
|||||||
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
|
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
|
||||||
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
||||||
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
|
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
|
||||||
- Local Blade/Tailwind cards are allowed only when they preserve dark
|
|
||||||
mode correctness, spacing consistency, badge semantics, action
|
|
||||||
hierarchy, progressive disclosure, accessibility, and overall
|
|
||||||
Filament visual language.
|
|
||||||
|
|
||||||
Native-by-default classification
|
Native-by-default classification
|
||||||
- `Native Surface` means the primary interaction contract is built from
|
- `Native Surface` means the primary interaction contract is built from
|
||||||
@ -1747,8 +1598,6 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
|||||||
more than one host, it becomes a `Shared Detail Micro-UI` and MUST
|
more than one host, it becomes a `Shared Detail Micro-UI` and MUST
|
||||||
define shared core vs host variation before another host reassembles
|
define shared core vs host variation before another host reassembles
|
||||||
it locally.
|
it locally.
|
||||||
- Local one-off markup MUST NOT recreate decision/diagnostics/raw
|
|
||||||
layering when an existing shared detail family is sufficient.
|
|
||||||
|
|
||||||
Upgrade-safe preference
|
Upgrade-safe preference
|
||||||
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
||||||
@ -1762,9 +1611,7 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
|||||||
- and the deviation is justified briefly in code and in the governing spec or PR.
|
- and the deviation is justified briefly in code and in the governing spec or PR.
|
||||||
- Approved exceptions MUST stay layout-neutral, use the minimum local
|
- Approved exceptions MUST stay layout-neutral, use the minimum local
|
||||||
classes necessary, MUST NOT invent a new page-local status language,
|
classes necessary, MUST NOT invent a new page-local status language,
|
||||||
MUST preserve dark mode correctness, spacing consistency,
|
and MUST say what remains standardized.
|
||||||
badge semantics, action hierarchy, progressive disclosure,
|
|
||||||
accessibility, and MUST say what remains standardized.
|
|
||||||
- `Hidden Exception` is forbidden. Historical accident or local
|
- `Hidden Exception` is forbidden. Historical accident or local
|
||||||
implementation convenience is not a valid substitute for UI-EX-001.
|
implementation convenience is not a valid substitute for UI-EX-001.
|
||||||
|
|
||||||
@ -1773,8 +1620,6 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
|||||||
- which native Filament element or shared primitive was used,
|
- which native Filament element or shared primitive was used,
|
||||||
- why an existing component was insufficient if an exception was taken,
|
- why an existing component was insufficient if an exception was taken,
|
||||||
- whether the surface is native, custom, or a shared detail family,
|
- whether the surface is native, custom, or a shared detail family,
|
||||||
- whether any local Blade/Tailwind card still preserves Filament
|
|
||||||
visual language and disclosure semantics,
|
|
||||||
- and whether any ad-hoc status, emphasis styling, or fake-native
|
- and whether any ad-hoc status, emphasis styling, or fake-native
|
||||||
contract was introduced.
|
contract was introduced.
|
||||||
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
||||||
@ -1813,11 +1658,6 @@ ### Scope, Compliance, and Review Expectations
|
|||||||
different policy, route terminal notifications through the lifecycle mechanism, include an `OperationRun UX Impact`
|
different policy, route terminal notifications through the lifecycle mechanism, include an `OperationRun UX Impact`
|
||||||
section in the active spec or plan, and document any temporary exception with an architecture note, test rationale,
|
section in the active spec or plan, and document any temporary exception with an architecture note, test rationale,
|
||||||
and migration decision.
|
and migration decision.
|
||||||
- Specs and PRs that change detail or status surfaces MUST explicitly
|
|
||||||
document how they satisfy customer-readable decision-first content,
|
|
||||||
diagnostics-secondary disclosure, support/raw-evidence gating, one
|
|
||||||
dominant next action, duplicate-truth prevention, and shared-pattern
|
|
||||||
reuse.
|
|
||||||
- Specs and PRs that change operator-facing surfaces MUST classify each
|
- Specs and PRs that change operator-facing surfaces MUST classify each
|
||||||
affected surface under DECIDE-001 and justify any new Primary
|
affected surface under DECIDE-001 and justify any new Primary
|
||||||
Decision Surface or workflow-first navigation change.
|
Decision Surface or workflow-first navigation change.
|
||||||
@ -1835,4 +1675,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 2.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-27
|
**Version**: 2.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-24
|
||||||
|
|||||||
@ -51,14 +51,6 @@ ## Signals, Exceptions, And Test Depth
|
|||||||
- [ ] CHK014 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
- [ ] CHK014 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
||||||
- [ ] CHK015 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
- [ ] CHK015 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
||||||
|
|
||||||
## Audience-Aware Disclosure And Decision Hierarchy
|
|
||||||
|
|
||||||
- [ ] CHK023 Default-visible content is decision-first and clearly separated from operator diagnostics and support/raw evidence.
|
|
||||||
- [ ] CHK024 Customer/read-only default paths do not expose raw JSON, copied context payloads, fingerprints, internal reason ownership, platform reason families, monitoring detail, or other debug semantics by default.
|
|
||||||
- [ ] CHK025 Exactly one dominant next action is primary; navigation or debug helpers such as `Open operation`, `Technical details`, or `Show JSON` do not compete at equal weight.
|
|
||||||
- [ ] CHK026 Duplicate visible status, blocker, reason, impact, or next-action summaries are removed or explicitly justified as non-duplicative evidence.
|
|
||||||
- [ ] CHK027 Support/raw sections are collapsed, lower-priority, or capability-gated where applicable, and any local Blade/Tailwind surface still preserves Filament visual language, dark mode correctness, progressive disclosure, and accessibility.
|
|
||||||
|
|
||||||
## Review Outcome
|
## Review Outcome
|
||||||
|
|
||||||
- [ ] CHK016 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
- [ ] CHK016 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
||||||
|
|||||||
@ -36,10 +36,6 @@ ## UI / Surface Guardrail Plan
|
|||||||
- **Native vs custom classification summary**: [native / custom / mixed / N/A]
|
- **Native vs custom classification summary**: [native / custom / mixed / N/A]
|
||||||
- **Shared-family relevance**: [none / list affected shared families]
|
- **Shared-family relevance**: [none / list affected shared families]
|
||||||
- **State layers in scope**: [shell / page / detail / URL-query / none]
|
- **State layers in scope**: [shell / page / detail / URL-query / none]
|
||||||
- **Audience modes in scope**: [customer/read-only / operator-MSP / support-platform / N/A]
|
|
||||||
- **Decision/diagnostic/raw hierarchy plan**: [decision-first / diagnostics-second / support-raw-third / N/A]
|
|
||||||
- **Raw/support gating plan**: [collapsed / capability-gated / role-gated / N/A]
|
|
||||||
- **One-primary-action / duplicate-truth control**: [how one dominant next action is preserved and repeated blockers are removed]
|
|
||||||
- **Handling modes by drift class or surface**: [hard-stop-candidate / review-mandatory / exception-required / report-only / N/A]
|
- **Handling modes by drift class or surface**: [hard-stop-candidate / review-mandatory / exception-required / report-only / N/A]
|
||||||
- **Repository-signal treatment**: [report-only / review-mandatory / exception-required / future hard-stop candidate / N/A]
|
- **Repository-signal treatment**: [report-only / review-mandatory / exception-required / future hard-stop candidate / N/A]
|
||||||
- **Special surface test profiles**: [standard-native-filament / shared-detail-family / monitoring-state-page / global-context-shell / exception-coded-surface / N/A]
|
- **Special surface test profiles**: [standard-native-filament / shared-detail-family / monitoring-state-page / global-context-shell / exception-coded-surface / N/A]
|
||||||
@ -115,10 +111,6 @@ ## Constitution Check
|
|||||||
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
||||||
- Filament-native UI (UI-FIL-001): if local Blade/Tailwind cards are
|
|
||||||
still necessary, they preserve dark mode correctness, spacing
|
|
||||||
consistency, badge semantics, action hierarchy, progressive
|
|
||||||
disclosure, accessibility, and Filament visual language
|
|
||||||
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
|
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
|
||||||
- Decision-first operating model (DECIDE-001): each changed
|
- Decision-first operating model (DECIDE-001): each changed
|
||||||
operator-facing surface is classified as Primary Decision,
|
operator-facing surface is classified as Primary Decision,
|
||||||
@ -128,13 +120,6 @@ ## Constitution Check
|
|||||||
disclosed, one governance case stays decidable in one context where
|
disclosed, one governance case stays decidable in one context where
|
||||||
practical, navigation follows workflows not storage structures, and
|
practical, navigation follows workflows not storage structures, and
|
||||||
automation / alerts reduce attention load instead of adding noise
|
automation / alerts reduce attention load instead of adding noise
|
||||||
- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): detail or
|
|
||||||
status surfaces separate customer-readable decision content,
|
|
||||||
operator diagnostics, and support/raw evidence; customer-readable
|
|
||||||
default paths hide raw JSON, copied context, fingerprints, internal
|
|
||||||
reason ownership, platform reason families, and debug semantics;
|
|
||||||
one dominant next action is explicit; duplicate visible truth is
|
|
||||||
removed
|
|
||||||
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
|
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
|
||||||
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): standard CRUD and Registry rows expose at most one inline safe shortcut; destructive actions are grouped or in the detail header; queue exceptions are catalogued, justified, and tested
|
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): standard CRUD and Registry rows expose at most one inline safe shortcut; destructive actions are grouped or in the detail header; queue exceptions are catalogued, justified, and tested
|
||||||
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): scope signals are truthful, canonical nouns stay stable across shells, critical operational truth is default-visible, and standard lists remain scanable
|
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): scope signals are truthful, canonical nouns stay stable across shells, critical operational truth is default-visible, and standard lists remain scanable
|
||||||
|
|||||||
@ -89,17 +89,6 @@ ## Decision-First Surface Role *(mandatory when operator-facing surfaces are cha
|
|||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
| e.g. Review inbox | Primary Decision Surface | Review and release queued governance work | Case summary, severity, recommendation, required action | Full evidence, raw payloads, audit trail, provider diagnostics | Primary because it is the queue where operators decide and clear work | Follows pending-decisions workflow, not storage objects | Removes search across runs, findings, and audit pages |
|
| e.g. Review inbox | Primary Decision Surface | Review and release queued governance work | Case summary, severity, recommendation, required action | Full evidence, raw payloads, audit trail, provider diagnostics | Primary because it is the queue where operators decide and clear work | Follows pending-decisions workflow, not storage objects | Removes search across runs, findings, and audit pages |
|
||||||
|
|
||||||
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
If this feature adds or materially changes a detail or status surface,
|
|
||||||
fill out one row per affected surface. Reuse the same surface names
|
|
||||||
used above and make the disclosure hierarchy explicit instead of
|
|
||||||
assuming it.
|
|
||||||
|
|
||||||
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| e.g. Review inbox | customer-read-only, operator-MSP, support-platform | Current status, why it matters, impact, recommendation, next action | Review history, lifecycle, related evidence, related runs | Raw payloads, fingerprints, reason ownership, platform reason family | `Review evidence` | Raw/support detail hidden or capability-gated outside support mode | The top summary states the blocker once; later sections add evidence rather than restating it |
|
|
||||||
|
|
||||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
||||||
@ -265,13 +254,6 @@ ## Requirements *(mandatory)*
|
|||||||
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
|
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
|
||||||
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
|
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
|
||||||
|
|
||||||
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** If this feature changes a detail or status surface, the spec MUST describe:
|
|
||||||
- how the surface separates customer-readable decision content, operator diagnostics, and support/raw evidence,
|
|
||||||
- which audience modes are in scope (`customer/read-only`, `operator/MSP`, `support/platform`),
|
|
||||||
- which content is hidden, collapsed, or capability-gated by default,
|
|
||||||
- how one dominant next action is preserved,
|
|
||||||
- and how duplicate visible truth is prevented.
|
|
||||||
|
|
||||||
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
|
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
|
||||||
- classify each touched seam as provider-owned or platform-core,
|
- classify each touched seam as provider-owned or platform-core,
|
||||||
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
|
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
|
||||||
@ -328,7 +310,6 @@ ## Requirements *(mandatory)*
|
|||||||
- which native Filament components or shared UI primitives are used,
|
- which native Filament components or shared UI primitives are used,
|
||||||
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
||||||
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
||||||
- how any required local Blade/Tailwind cards still preserve dark mode correctness, spacing consistency, badge semantics, action hierarchy, progressive disclosure, accessibility, and Filament visual language,
|
|
||||||
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||||
@ -386,7 +367,6 @@ ## Requirements *(mandatory)*
|
|||||||
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||||
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
||||||
- which diagnostics are secondary and how they are explicitly revealed,
|
- which diagnostics are secondary and how they are explicitly revealed,
|
||||||
- how the dominant next action stays primary and how duplicate visible truth is avoided,
|
|
||||||
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
||||||
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
||||||
|
|||||||
@ -78,21 +78,9 @@ # Tasks: [FEATURE NAME]
|
|||||||
- filling the spec’s Operator Surface Contract for every affected page,
|
- filling the spec’s Operator Surface Contract for every affected page,
|
||||||
- keeping default-visible content limited to first-decision needs and
|
- keeping default-visible content limited to first-decision needs and
|
||||||
moving proof, payloads, and diagnostics into progressive disclosure,
|
moving proof, payloads, and diagnostics into progressive disclosure,
|
||||||
- implementing the three-tier disclosure hierarchy where applicable:
|
|
||||||
customer-readable decision content first, operator diagnostics
|
|
||||||
second, support/raw evidence third,
|
|
||||||
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
||||||
- ensuring customer/read-only default paths do not expose raw JSON,
|
|
||||||
copied context payloads, fingerprints, internal reason ownership,
|
|
||||||
platform reason families, or debug semantics,
|
|
||||||
- keeping each governance case decidable in one focused context where
|
- keeping each governance case decidable in one focused context where
|
||||||
practical instead of forcing cross-page reconstruction,
|
practical instead of forcing cross-page reconstruction,
|
||||||
- keeping exactly one dominant next action primary and demoting
|
|
||||||
navigation/debug helpers such as `Open operation`, `Technical
|
|
||||||
details`, or `Show JSON`,
|
|
||||||
- removing duplicate visible status, blocker, reason, impact, or
|
|
||||||
next-action summaries so later sections add evidence instead of
|
|
||||||
restating the same decision truth,
|
|
||||||
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
||||||
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
||||||
@ -140,12 +128,6 @@ # Tasks: [FEATURE NAME]
|
|||||||
- documenting any catalogued UI exception in the spec/PR and adding dedicated test coverage,
|
- documenting any catalogued UI exception in the spec/PR and adding dedicated test coverage,
|
||||||
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
||||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||||
- For any new or modified customer/operator-facing detail surface,
|
|
||||||
tests MUST prove default-visible status/reason/impact/next-action
|
|
||||||
content, exactly one dominant next action, diagnostics-secondary
|
|
||||||
ordering, hidden raw/support detail by default, capability-gated
|
|
||||||
support/raw sections where applicable, and the absence of duplicate
|
|
||||||
visible decision summaries.
|
|
||||||
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
||||||
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
||||||
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
|
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages\Workspaces;
|
namespace App\Filament\Pages\Workspaces;
|
||||||
|
|
||||||
use BackedEnum;
|
|
||||||
use App\Exceptions\Onboarding\OnboardingDraftConflictException;
|
use App\Exceptions\Onboarding\OnboardingDraftConflictException;
|
||||||
use App\Exceptions\Onboarding\OnboardingDraftImmutableException;
|
use App\Exceptions\Onboarding\OnboardingDraftImmutableException;
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
@ -52,7 +51,6 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\ProductKnowledge\ContextualHelpResolver;
|
|
||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
@ -996,7 +994,6 @@ private function routeBoundReadinessSchema(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$payload = $this->onboardingReadinessPayload($draft);
|
$payload = $this->onboardingReadinessPayload($draft);
|
||||||
$primaryNextAction = $this->readinessPrimaryNextActionComponent($payload, 'route_bound_readiness');
|
|
||||||
|
|
||||||
$schema = [
|
$schema = [
|
||||||
Section::make('Onboarding readiness')
|
Section::make('Onboarding readiness')
|
||||||
@ -1004,7 +1001,7 @@ private function routeBoundReadinessSchema(): array
|
|||||||
->compact()
|
->compact()
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->schema([
|
->schema([
|
||||||
Text::make('Step')
|
Text::make('Current checkpoint')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
Text::make($payload['checkpoint']['current_checkpoint_label'] ?? '—')
|
Text::make($payload['checkpoint']['current_checkpoint_label'] ?? '—')
|
||||||
->badge()
|
->badge()
|
||||||
@ -1024,7 +1021,9 @@ private function routeBoundReadinessSchema(): array
|
|||||||
Text::make($payload['freshness']['note']),
|
Text::make($payload['freshness']['note']),
|
||||||
Text::make('Primary next action')
|
Text::make('Primary next action')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
$primaryNextAction,
|
Text::make($payload['next_action']['label'])
|
||||||
|
->badge()
|
||||||
|
->color($this->readinessNextActionColor($payload['next_action']['kind'])),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -1065,14 +1064,8 @@ private function draftCompactReadinessSchema(TenantOnboardingSession $draft): ar
|
|||||||
private function readinessSupportingEvidenceSchema(array $payload, string $keyPrefix): array
|
private function readinessSupportingEvidenceSchema(array $payload, string $keyPrefix): array
|
||||||
{
|
{
|
||||||
$links = is_array($payload['supporting_links'] ?? null) ? $payload['supporting_links'] : [];
|
$links = is_array($payload['supporting_links'] ?? null) ? $payload['supporting_links'] : [];
|
||||||
$assist = is_array($payload['verification_assist'] ?? null) ? $payload['verification_assist'] : [];
|
|
||||||
$showAssist = (bool) ($assist['is_visible'] ?? false);
|
|
||||||
$permissions = is_array($payload['permissions'] ?? null) ? $payload['permissions'] : [];
|
|
||||||
$requiredPermissionsUrl = is_string($permissions['required_permissions_url'] ?? null)
|
|
||||||
? $permissions['required_permissions_url']
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if ($links === [] && ! ($showAssist && $requiredPermissionsUrl !== null && $requiredPermissionsUrl !== '')) {
|
if ($links === []) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1096,20 +1089,13 @@ private function readinessSupportingEvidenceSchema(array $payload, string $keyPr
|
|||||||
->url($url);
|
->url($url);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($showAssist && $requiredPermissionsUrl !== null && $requiredPermissionsUrl !== '') {
|
|
||||||
$actions[] = Action::make($keyPrefix.'_required_permissions_assist')
|
|
||||||
->label('View required permissions')
|
|
||||||
->color('gray')
|
|
||||||
->url($requiredPermissionsUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($actions === []) {
|
if ($actions === []) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Section::make('Supporting evidence')
|
Section::make('Supporting evidence')
|
||||||
->description('Open canonical operation detail or secondary permission evidence when deeper diagnostics are needed.')
|
->description('Open canonical operation detail when deeper diagnostics are needed.')
|
||||||
->compact()
|
->compact()
|
||||||
->schema([
|
->schema([
|
||||||
SchemaActions::make($actions)->key($keyPrefix.'_supporting_evidence_actions'),
|
SchemaActions::make($actions)->key($keyPrefix.'_supporting_evidence_actions'),
|
||||||
@ -1129,16 +1115,14 @@ private function readinessPermissionDiagnosticsSchema(array $payload, string $ke
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((bool) ($payload['verification']['has_report'] ?? false)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$counts = is_array($permissions['counts'] ?? null) ? $permissions['counts'] : [];
|
$counts = is_array($permissions['counts'] ?? null) ? $permissions['counts'] : [];
|
||||||
$missingApplication = (int) ($counts['missing_application'] ?? 0);
|
$missingApplication = (int) ($counts['missing_application'] ?? 0);
|
||||||
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
|
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
|
||||||
$errors = (int) ($counts['error'] ?? 0);
|
$errors = (int) ($counts['error'] ?? 0);
|
||||||
|
$assist = is_array($payload['verification_assist'] ?? null) ? $payload['verification_assist'] : [];
|
||||||
|
$isVisible = (bool) ($assist['is_visible'] ?? false);
|
||||||
|
|
||||||
if ($missingApplication + $missingDelegated + $errors === 0) {
|
if ($missingApplication + $missingDelegated + $errors === 0 && ! $isVisible) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1193,7 +1177,7 @@ private function readinessPermissionDiagnosticsSchema(array $payload, string $ke
|
|||||||
* draft: array{id: int, tenant_name: string, stage_label: string, draft_status_label: string, started_by: string, updated_by: string, last_updated_human: string},
|
* draft: array{id: int, tenant_name: string, stage_label: string, draft_status_label: string, started_by: string, updated_by: string, last_updated_human: string},
|
||||||
* checkpoint: array{current_checkpoint: string|null, current_checkpoint_label: string|null, last_completed_checkpoint: string|null, lifecycle_state: string, lifecycle_label: string},
|
* checkpoint: array{current_checkpoint: string|null, current_checkpoint_label: string|null, last_completed_checkpoint: string|null, lifecycle_state: string, lifecycle_label: string},
|
||||||
* provider_summary: array<string, mixed>|null,
|
* provider_summary: array<string, mixed>|null,
|
||||||
* verification: array{status: string, status_label: string, run_id: int|null, run_url: string|null, is_active: bool, has_report: bool, matches_selected_connection: bool|null, overall: string|null},
|
* verification: array{status: string, status_label: string, run_id: int|null, run_url: string|null, is_active: bool, matches_selected_connection: bool|null, overall: string|null},
|
||||||
* verification_assist: array{is_visible: bool, reason: string},
|
* verification_assist: array{is_visible: bool, reason: string},
|
||||||
* permissions: array{overall: string|null, counts: array<string, int>, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list<string>, delegated: list<string>}, required_permissions_url: string|null}|null,
|
* permissions: array{overall: string|null, counts: array<string, int>, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list<string>, delegated: list<string>}, required_permissions_url: string|null}|null,
|
||||||
* freshness: array{connection_recently_updated: bool, verification_mismatch: bool, permission_last_refreshed_at: string|null, permission_data_is_stale: bool, note: string},
|
* freshness: array{connection_recently_updated: bool, verification_mismatch: bool, permission_last_refreshed_at: string|null, permission_data_is_stale: bool, note: string},
|
||||||
@ -1234,9 +1218,6 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr
|
|||||||
$permissions = $tenant instanceof Tenant ? $this->readinessPermissionOverview($tenant) : null;
|
$permissions = $tenant instanceof Tenant ? $this->readinessPermissionOverview($tenant) : null;
|
||||||
$verificationReport = $verificationRun instanceof OperationRun ? VerificationReportViewer::report($verificationRun) : null;
|
$verificationReport = $verificationRun instanceof OperationRun ? VerificationReportViewer::report($verificationRun) : null;
|
||||||
$verificationReport = is_array($verificationReport) ? $verificationReport : null;
|
$verificationReport = is_array($verificationReport) ? $verificationReport : null;
|
||||||
$verificationPrimaryReasonCode = $verificationReport !== null
|
|
||||||
? app(ContextualHelpResolver::class)->primaryReasonCodeFromVerificationReport($verificationReport)
|
|
||||||
: null;
|
|
||||||
$permissionFreshness = is_array($permissions['freshness'] ?? null) ? $permissions['freshness'] : [
|
$permissionFreshness = is_array($permissions['freshness'] ?? null) ? $permissions['freshness'] : [
|
||||||
'last_refreshed_at' => null,
|
'last_refreshed_at' => null,
|
||||||
'is_stale' => true,
|
'is_stale' => true,
|
||||||
@ -1256,9 +1237,6 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr
|
|||||||
verificationMismatch: $verificationMismatch,
|
verificationMismatch: $verificationMismatch,
|
||||||
);
|
);
|
||||||
|
|
||||||
$reasonCode = is_string($snapshot['reason_code'] ?? null) ? $snapshot['reason_code'] : null;
|
|
||||||
$blockingReasonCode = is_string($snapshot['blocking_reason_code'] ?? null) ? $snapshot['blocking_reason_code'] : null;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'draft' => [
|
'draft' => [
|
||||||
'id' => (int) $draft->getKey(),
|
'id' => (int) $draft->getKey(),
|
||||||
@ -1285,7 +1263,6 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr
|
|||||||
? OperationRunLinks::tenantlessView($verificationRun)
|
? OperationRunLinks::tenantlessView($verificationRun)
|
||||||
: null,
|
: null,
|
||||||
'is_active' => $verificationRun instanceof OperationRun && $verificationRun->status !== OperationRunStatus::Completed->value,
|
'is_active' => $verificationRun instanceof OperationRun && $verificationRun->status !== OperationRunStatus::Completed->value,
|
||||||
'has_report' => $verificationReport !== null,
|
|
||||||
'matches_selected_connection' => $verificationMatchesSelectedConnection,
|
'matches_selected_connection' => $verificationMatchesSelectedConnection,
|
||||||
'overall' => $verificationRun instanceof OperationRun
|
'overall' => $verificationRun instanceof OperationRun
|
||||||
? $this->readinessVerificationOverall($verificationRun, $verificationReport)
|
? $this->readinessVerificationOverall($verificationRun, $verificationReport)
|
||||||
@ -1309,8 +1286,8 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
'blocker' => [
|
'blocker' => [
|
||||||
'reason_code' => $reasonCode,
|
'reason_code' => is_string($snapshot['reason_code'] ?? null) ? $snapshot['reason_code'] : null,
|
||||||
'blocking_reason_code' => $blockingReasonCode,
|
'blocking_reason_code' => is_string($snapshot['blocking_reason_code'] ?? null) ? $snapshot['blocking_reason_code'] : null,
|
||||||
'operator_summary' => $readinessSummary,
|
'operator_summary' => $readinessSummary,
|
||||||
],
|
],
|
||||||
'next_action' => $this->readinessNextAction(
|
'next_action' => $this->readinessNextAction(
|
||||||
@ -1320,7 +1297,6 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr
|
|||||||
verificationRun: $verificationRun,
|
verificationRun: $verificationRun,
|
||||||
verificationStatus: $verificationStatus,
|
verificationStatus: $verificationStatus,
|
||||||
permissions: $permissions,
|
permissions: $permissions,
|
||||||
blockerReasonCode: $verificationPrimaryReasonCode ?? $blockingReasonCode ?? $reasonCode,
|
|
||||||
connectionRecentlyUpdated: $connectionRecentlyUpdated,
|
connectionRecentlyUpdated: $connectionRecentlyUpdated,
|
||||||
verificationMismatch: $verificationMismatch,
|
verificationMismatch: $verificationMismatch,
|
||||||
supportingLinks: $supportingLinks,
|
supportingLinks: $supportingLinks,
|
||||||
@ -1398,35 +1374,6 @@ private function readinessNextActionColor(string $kind): string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
*/
|
|
||||||
private function readinessPrimaryNextActionComponent(array $payload, string $keyPrefix): \Filament\Schemas\Components\Component
|
|
||||||
{
|
|
||||||
$nextAction = is_array($payload['next_action'] ?? null) ? $payload['next_action'] : [];
|
|
||||||
$label = is_string($nextAction['label'] ?? null) && trim((string) $nextAction['label']) !== ''
|
|
||||||
? trim((string) $nextAction['label'])
|
|
||||||
: 'Continue onboarding';
|
|
||||||
$kind = is_string($nextAction['kind'] ?? null) ? $nextAction['kind'] : 'gray';
|
|
||||||
$url = is_string($nextAction['url'] ?? null) && trim((string) $nextAction['url']) !== ''
|
|
||||||
? trim((string) $nextAction['url'])
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if ($url !== null) {
|
|
||||||
return SchemaActions::make([
|
|
||||||
Action::make($keyPrefix.'_primary_next_action')
|
|
||||||
->label($label)
|
|
||||||
->color($this->readinessNextActionColor($kind))
|
|
||||||
->url($url)
|
|
||||||
->openUrlInNewTab(str_starts_with($url, 'http://') || str_starts_with($url, 'https://')),
|
|
||||||
])->key($keyPrefix.'_primary_next_action');
|
|
||||||
}
|
|
||||||
|
|
||||||
return Text::make($label)
|
|
||||||
->badge()
|
|
||||||
->color($this->readinessNextActionColor($kind));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function readinessProviderConnection(TenantOnboardingSession $draft): ?ProviderConnection
|
private function readinessProviderConnection(TenantOnboardingSession $draft): ?ProviderConnection
|
||||||
{
|
{
|
||||||
$state = is_array($draft->state) ? $draft->state : [];
|
$state = is_array($draft->state) ? $draft->state : [];
|
||||||
@ -1460,8 +1407,8 @@ private function readinessProviderSummary(?ProviderConnection $connection): ?arr
|
|||||||
return [
|
return [
|
||||||
'provider' => (string) $connection->provider,
|
'provider' => (string) $connection->provider,
|
||||||
'target_scope' => [],
|
'target_scope' => [],
|
||||||
'consent_state' => $this->stringValue($connection->consent_status),
|
'consent_state' => (string) $connection->consent_status,
|
||||||
'verification_state' => $this->stringValue($connection->verification_status),
|
'verification_state' => (string) $connection->verification_status,
|
||||||
'readiness_summary' => 'Target scope needs review',
|
'readiness_summary' => 'Target scope needs review',
|
||||||
'target_scope_summary' => 'Target scope needs review',
|
'target_scope_summary' => 'Target scope needs review',
|
||||||
'contextual_identity_line' => null,
|
'contextual_identity_line' => null,
|
||||||
@ -1667,7 +1614,6 @@ private function readinessNextAction(
|
|||||||
?OperationRun $verificationRun,
|
?OperationRun $verificationRun,
|
||||||
string $verificationStatus,
|
string $verificationStatus,
|
||||||
?array $permissions,
|
?array $permissions,
|
||||||
?string $blockerReasonCode,
|
|
||||||
bool $connectionRecentlyUpdated,
|
bool $connectionRecentlyUpdated,
|
||||||
bool $verificationMismatch,
|
bool $verificationMismatch,
|
||||||
array $supportingLinks,
|
array $supportingLinks,
|
||||||
@ -1693,7 +1639,7 @@ private function readinessNextAction(
|
|||||||
|
|
||||||
if ($consentState !== ProviderConsentStatus::Granted->value) {
|
if ($consentState !== ProviderConsentStatus::Granted->value) {
|
||||||
return $this->readinessAction(
|
return $this->readinessAction(
|
||||||
label: 'Grant admin consent',
|
label: 'Grant consent',
|
||||||
kind: 'grant_consent',
|
kind: 'grant_consent',
|
||||||
url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($draft->tenant) : null,
|
url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($draft->tenant) : null,
|
||||||
);
|
);
|
||||||
@ -1701,18 +1647,6 @@ private function readinessNextAction(
|
|||||||
|
|
||||||
$permissionOverall = is_string($permissions['overall'] ?? null) ? $permissions['overall'] : null;
|
$permissionOverall = is_string($permissions['overall'] ?? null) ? $permissions['overall'] : null;
|
||||||
|
|
||||||
if (in_array($blockerReasonCode, [
|
|
||||||
ProviderReasonCodes::ProviderConsentMissing,
|
|
||||||
ProviderReasonCodes::ProviderConsentFailed,
|
|
||||||
ProviderReasonCodes::ProviderConsentRevoked,
|
|
||||||
], true)) {
|
|
||||||
return $this->readinessAction(
|
|
||||||
label: 'Grant admin consent',
|
|
||||||
kind: 'grant_consent',
|
|
||||||
url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($draft->tenant) : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($permissionOverall === VerificationReportOverall::Blocked->value) {
|
if ($permissionOverall === VerificationReportOverall::Blocked->value) {
|
||||||
return $this->readinessAction(
|
return $this->readinessAction(
|
||||||
label: 'Review permissions',
|
label: 'Review permissions',
|
||||||
@ -2843,7 +2777,6 @@ private function verificationReportViewData(): array
|
|||||||
'acknowledgements' => [],
|
'acknowledgements' => [],
|
||||||
'surface' => [],
|
'surface' => [],
|
||||||
'redactionNotes' => [],
|
'redactionNotes' => [],
|
||||||
'contextualHelp' => null,
|
|
||||||
'assistVisibility' => $assistVisibility,
|
'assistVisibility' => $assistVisibility,
|
||||||
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
||||||
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
||||||
@ -2853,7 +2786,6 @@ private function verificationReportViewData(): array
|
|||||||
|
|
||||||
$report = VerificationReportViewer::report($run);
|
$report = VerificationReportViewer::report($run);
|
||||||
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
|
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
|
||||||
$contextualHelp = is_array($report) ? $this->verificationContextualHelp($report, $run) : null;
|
|
||||||
|
|
||||||
$changeIndicator = VerificationReportChangeIndicator::forRun($run);
|
$changeIndicator = VerificationReportChangeIndicator::forRun($run);
|
||||||
$previousRunUrl = $this->verificationPreviousRunUrl($changeIndicator);
|
$previousRunUrl = $this->verificationPreviousRunUrl($changeIndicator);
|
||||||
@ -2940,7 +2872,6 @@ private function verificationReportViewData(): array
|
|||||||
'acknowledgements' => $acknowledgements,
|
'acknowledgements' => $acknowledgements,
|
||||||
'surface' => $surface,
|
'surface' => $surface,
|
||||||
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
||||||
'contextualHelp' => $contextualHelp,
|
|
||||||
'assistVisibility' => $assistVisibility,
|
'assistVisibility' => $assistVisibility,
|
||||||
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
||||||
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
||||||
@ -2948,40 +2879,6 @@ private function verificationReportViewData(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $verificationReport
|
|
||||||
* @return array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
private function verificationContextualHelp(array $verificationReport, OperationRun $run): ?array
|
|
||||||
{
|
|
||||||
$tenant = $this->managedTenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolver = app(ContextualHelpResolver::class);
|
|
||||||
$reasonCode = $resolver->primaryReasonCodeFromVerificationReport($verificationReport);
|
|
||||||
$topicKey = $resolver->topicKeyForOnboardingVerification(
|
|
||||||
reasonCode: $reasonCode,
|
|
||||||
isVerificationStale: $this->verificationRunIsStaleForSelectedConnection(),
|
|
||||||
verificationOverall: is_string(data_get($verificationReport, 'summary.overall'))
|
|
||||||
? (string) data_get($verificationReport, 'summary.overall')
|
|
||||||
: null,
|
|
||||||
runOutcome: is_string($run->outcome) ? (string) $run->outcome : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($topicKey === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $resolver->tryResolve($topicKey, [
|
|
||||||
'tenant' => $tenant,
|
|
||||||
'reason_code' => $reasonCode,
|
|
||||||
'surface' => 'onboarding',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function wizardVerificationRequiredPermissionsAssistAction(): Action
|
public function wizardVerificationRequiredPermissionsAssistAction(): Action
|
||||||
{
|
{
|
||||||
return Action::make('wizardVerificationRequiredPermissionsAssist')
|
return Action::make('wizardVerificationRequiredPermissionsAssist')
|
||||||
@ -4610,19 +4507,6 @@ private function completionSummaryConnectionSummary(): string
|
|||||||
return sprintf('%s - %s', $label, $detail);
|
return sprintf('%s - %s', $label, $detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function stringValue(mixed $value): string
|
|
||||||
{
|
|
||||||
if ($value instanceof BackedEnum) {
|
|
||||||
return (string) $value->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($value) || is_int($value) || is_float($value) || is_bool($value)) {
|
|
||||||
return (string) $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function completionSummaryVerificationDetail(): string
|
private function completionSummaryVerificationDetail(): string
|
||||||
{
|
{
|
||||||
$counts = $this->verificationReportCounts();
|
$counts = $this->verificationReportCounts();
|
||||||
|
|||||||
@ -6,8 +6,6 @@
|
|||||||
|
|
||||||
use App\Filament\System\Widgets\ControlTowerHealthIndicator;
|
use App\Filament\System\Widgets\ControlTowerHealthIndicator;
|
||||||
use App\Filament\System\Widgets\ControlTowerKpis;
|
use App\Filament\System\Widgets\ControlTowerKpis;
|
||||||
use App\Filament\System\Widgets\CustomerHealthKpis;
|
|
||||||
use App\Filament\System\Widgets\CustomerHealthTopWorkspaces;
|
|
||||||
use App\Filament\System\Widgets\ProductTelemetryKpis;
|
use App\Filament\System\Widgets\ProductTelemetryKpis;
|
||||||
use App\Filament\System\Widgets\ControlTowerRecentFailures;
|
use App\Filament\System\Widgets\ControlTowerRecentFailures;
|
||||||
use App\Filament\System\Widgets\ControlTowerTopOffenders;
|
use App\Filament\System\Widgets\ControlTowerTopOffenders;
|
||||||
@ -64,12 +62,6 @@ public function getWidgets(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
ControlTowerHealthIndicator::class,
|
ControlTowerHealthIndicator::class,
|
||||||
new WidgetConfiguration(CustomerHealthKpis::class, [
|
|
||||||
'window' => $this->window,
|
|
||||||
]),
|
|
||||||
new WidgetConfiguration(CustomerHealthTopWorkspaces::class, [
|
|
||||||
'window' => $this->window,
|
|
||||||
]),
|
|
||||||
new WidgetConfiguration(ControlTowerKpis::class, [
|
new WidgetConfiguration(ControlTowerKpis::class, [
|
||||||
'window' => $this->window,
|
'window' => $this->window,
|
||||||
]),
|
]),
|
||||||
|
|||||||
@ -1,144 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\System\Pages\Directory\Concerns;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\CustomerHealth\CustomerHealthDimensionCatalog;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
|
|
||||||
trait BuildsCustomerHealthDecisionData
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array{
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>
|
|
||||||
* } $summary
|
|
||||||
* @return array{
|
|
||||||
* overall: array{label: string, color: string, icon: string|null},
|
|
||||||
* reason: string,
|
|
||||||
* impact: string,
|
|
||||||
* recommended_action: string,
|
|
||||||
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
|
|
||||||
* window_label: string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
protected function buildCustomerHealthDecision(array $summary, SystemConsoleWindow $window, string $surface): array
|
|
||||||
{
|
|
||||||
$overallBadge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $summary['overall_level']);
|
|
||||||
|
|
||||||
$dominantDimensions = collect($summary['dominant_dimension_keys'])
|
|
||||||
->map(function (string $dimensionKey) use ($summary): ?array {
|
|
||||||
$dimension = $summary['dimensions'][$dimensionKey] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($dimension)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$badge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $dimension['level']);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'label' => $dimension['label'],
|
|
||||||
'color' => $badge->color,
|
|
||||||
'icon' => $badge->icon,
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->filter()
|
|
||||||
->take(2)
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$dominantLabels = array_map(static fn (array $dimension): string => $dimension['label'], $dominantDimensions);
|
|
||||||
$primaryDimension = $summary['dominant_dimension_keys'][0] ?? null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'overall' => [
|
|
||||||
'label' => $overallBadge->label,
|
|
||||||
'color' => $overallBadge->color,
|
|
||||||
'icon' => $overallBadge->icon,
|
|
||||||
],
|
|
||||||
'reason' => $this->customerHealthReason($dominantLabels),
|
|
||||||
'impact' => $this->customerHealthImpact($summary['overall_level'], $primaryDimension),
|
|
||||||
'recommended_action' => $this->customerHealthRecommendedAction($summary['overall_level'], $primaryDimension, $surface),
|
|
||||||
'dominant_dimensions' => $dominantDimensions,
|
|
||||||
'window_label' => SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $dominantLabels
|
|
||||||
*/
|
|
||||||
protected function customerHealthReason(array $dominantLabels): string
|
|
||||||
{
|
|
||||||
if ($dominantLabels === []) {
|
|
||||||
return 'No active health drivers are pressuring this workspace right now.';
|
|
||||||
}
|
|
||||||
|
|
||||||
$labelPrefix = count($dominantLabels) === 1 ? 'Top driver' : 'Top drivers';
|
|
||||||
|
|
||||||
return $labelPrefix.': '.implode(', ', $dominantLabels);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function customerHealthImpact(string $overallLevel, ?string $primaryDimension): string
|
|
||||||
{
|
|
||||||
if ($overallLevel === 'ok') {
|
|
||||||
return 'Tracked onboarding, provider, operational, governance, review-pack, and engagement signals are currently stable.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($overallLevel === 'unknown') {
|
|
||||||
return 'Some required health truth is missing or stale, so this workspace cannot be treated as healthy yet.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($primaryDimension) {
|
|
||||||
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $overallLevel === 'critical'
|
|
||||||
? 'Onboarding readiness is blocked, so this workspace cannot be treated as operationally ready.'
|
|
||||||
: 'Onboarding readiness still needs follow-up before this workspace can be treated as fully stable.',
|
|
||||||
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $overallLevel === 'critical'
|
|
||||||
? 'Default provider consent or verification is blocking reliable tenant management.'
|
|
||||||
: 'Provider connectivity has degraded and may impact reliable tenant management.',
|
|
||||||
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => $overallLevel === 'critical'
|
|
||||||
? 'Failed or stuck operations are actively putting delivery at risk.'
|
|
||||||
: 'Recent operational noise is starting to erode delivery confidence.',
|
|
||||||
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $overallLevel === 'critical'
|
|
||||||
? 'High-severity or expired governance pressure needs immediate review.'
|
|
||||||
: 'Governance pressure is active and should be reviewed before it escalates.',
|
|
||||||
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => $overallLevel === 'critical'
|
|
||||||
? 'Recent review-pack work is unusable or expired, so review readiness is blocked.'
|
|
||||||
: 'Review-pack readiness is incomplete, so recent review evidence may not be usable yet.',
|
|
||||||
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $overallLevel === 'critical'
|
|
||||||
? 'Recent product activity is missing, which suggests active usage may be deteriorating.'
|
|
||||||
: 'Recent product activity is thinning out and may indicate adoption drift.',
|
|
||||||
default => $overallLevel === 'critical'
|
|
||||||
? 'This workspace needs immediate operator follow-up.'
|
|
||||||
: 'This workspace needs follow-up soon to prevent further drift.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function customerHealthRecommendedAction(string $overallLevel, ?string $primaryDimension, string $surface): string
|
|
||||||
{
|
|
||||||
if ($overallLevel === 'ok') {
|
|
||||||
return 'Continue normal monitoring from the system dashboard.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($primaryDimension) {
|
|
||||||
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $surface === 'tenant'
|
|
||||||
? 'Confirm the tenant onboarding state with the responsible tenant admin and clear the blocking step.'
|
|
||||||
: 'Open the affected tenant below and confirm which onboarding step is blocked.',
|
|
||||||
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $surface === 'tenant'
|
|
||||||
? 'Review connectivity signals below and confirm the default provider consent and verification state.'
|
|
||||||
: 'Open the affected tenant below and review the default provider connection state.',
|
|
||||||
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => 'Review recent operations below and triage failed or stuck runs first.',
|
|
||||||
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $surface === 'tenant'
|
|
||||||
? 'Review governance findings or exception pressure for this tenant before proceeding.'
|
|
||||||
: 'Open the affected tenant below and review governance findings or exceptions.',
|
|
||||||
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => 'Check recent review-pack activity and confirm that a usable pack exists for the current window.',
|
|
||||||
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $surface === 'tenant'
|
|
||||||
? 'Confirm whether missing recent product activity is expected for this tenant.'
|
|
||||||
: 'Confirm whether missing recent product activity is expected across this workspace.',
|
|
||||||
default => 'Review the diagnostics below to confirm which source truth needs operator follow-up.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,25 +4,20 @@
|
|||||||
|
|
||||||
namespace App\Filament\System\Pages\Directory;
|
namespace App\Filament\System\Pages\Directory;
|
||||||
|
|
||||||
use App\Filament\System\Pages\Directory\Concerns\BuildsCustomerHealthDecisionData;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantPermission;
|
use App\Models\TenantPermission;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
use App\Support\System\SystemDirectoryLinks;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class ViewTenant extends Page
|
class ViewTenant extends Page
|
||||||
{
|
{
|
||||||
use BuildsCustomerHealthDecisionData;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static ?string $slug = 'directory/tenants/{tenant}';
|
protected static ?string $slug = 'directory/tenants/{tenant}';
|
||||||
@ -107,26 +102,4 @@ public function runsUrl(): string
|
|||||||
{
|
{
|
||||||
return SystemOperationRunLinks::index();
|
return SystemOperationRunLinks::index();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* overall: array{label: string, color: string, icon: string|null},
|
|
||||||
* reason: string,
|
|
||||||
* impact: string,
|
|
||||||
* recommended_action: string,
|
|
||||||
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
|
|
||||||
* window_label: string
|
|
||||||
* }|null
|
|
||||||
*/
|
|
||||||
public function customerHealthDecision(): ?array
|
|
||||||
{
|
|
||||||
$window = SystemConsoleWindow::fromNullable(request()->query('window'));
|
|
||||||
$summary = app(WorkspaceHealthSummaryQuery::class)->summaryForWorkspace((int) $this->tenant->workspace_id, $window);
|
|
||||||
|
|
||||||
if (! is_array($summary)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->buildCustomerHealthDecision($summary, $window, 'tenant');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,24 +4,19 @@
|
|||||||
|
|
||||||
namespace App\Filament\System\Pages\Directory;
|
namespace App\Filament\System\Pages\Directory;
|
||||||
|
|
||||||
use App\Filament\System\Pages\Directory\Concerns\BuildsCustomerHealthDecisionData;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
use App\Support\System\SystemDirectoryLinks;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class ViewWorkspace extends Page
|
class ViewWorkspace extends Page
|
||||||
{
|
{
|
||||||
use BuildsCustomerHealthDecisionData;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static ?string $slug = 'directory/workspaces/{workspace}';
|
protected static ?string $slug = 'directory/workspaces/{workspace}';
|
||||||
@ -84,26 +79,4 @@ public function runsUrl(): string
|
|||||||
{
|
{
|
||||||
return SystemOperationRunLinks::index();
|
return SystemOperationRunLinks::index();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* overall: array{label: string, color: string, icon: string|null},
|
|
||||||
* reason: string,
|
|
||||||
* impact: string,
|
|
||||||
* recommended_action: string,
|
|
||||||
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
|
|
||||||
* window_label: string
|
|
||||||
* }|null
|
|
||||||
*/
|
|
||||||
public function customerHealthDecision(): ?array
|
|
||||||
{
|
|
||||||
$window = SystemConsoleWindow::fromNullable(request()->query('window'));
|
|
||||||
$summary = app(WorkspaceHealthSummaryQuery::class)->summaryForWorkspace($this->workspace, $window);
|
|
||||||
|
|
||||||
if (! is_array($summary)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->buildCustomerHealthDecision($summary, $window, 'workspace');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\System\Widgets;
|
|
||||||
|
|
||||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
|
||||||
|
|
||||||
class CustomerHealthKpis extends StatsOverviewWidget
|
|
||||||
{
|
|
||||||
protected static bool $isLazy = false;
|
|
||||||
|
|
||||||
protected ?string $heading = 'Customer health';
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
public ?string $window = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Stat>
|
|
||||||
*/
|
|
||||||
protected function getStats(): array
|
|
||||||
{
|
|
||||||
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
|
|
||||||
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
|
|
||||||
$counts = app(WorkspaceHealthSummaryQuery::class)->healthCounts($window);
|
|
||||||
|
|
||||||
return [
|
|
||||||
Stat::make('Healthy', $counts['ok'])
|
|
||||||
->description(sprintf('Operational stability, review-pack readiness, and engagement freshness honor %s.', $windowLabel))
|
|
||||||
->color($counts['ok'] > 0 ? 'success' : 'gray'),
|
|
||||||
Stat::make('Warning', $counts['warn'])
|
|
||||||
->description('Onboarding readiness, provider health, and governance pressure stay point-in-time.')
|
|
||||||
->color($counts['warn'] > 0 ? 'warning' : 'gray'),
|
|
||||||
Stat::make('Critical', $counts['critical'])
|
|
||||||
->description('Overall workspace health is derived from existing system truth only.')
|
|
||||||
->color($counts['critical'] > 0 ? 'danger' : 'gray'),
|
|
||||||
Stat::make('Unknown', $counts['unknown'])
|
|
||||||
->description('Missing or stale inputs stay explicit instead of silently reading healthy.')
|
|
||||||
->color('gray'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,137 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\System\Widgets;
|
|
||||||
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
|
||||||
use Filament\Widgets\Widget;
|
|
||||||
|
|
||||||
class CustomerHealthTopWorkspaces extends Widget
|
|
||||||
{
|
|
||||||
protected static bool $isLazy = false;
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
protected string $view = 'filament.system.widgets.customer-health-top-workspaces';
|
|
||||||
|
|
||||||
public ?string $window = null;
|
|
||||||
|
|
||||||
public static function canView(): bool
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
if (! $user instanceof PlatformUser) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! static::canOpenRuns($user)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
|
||||||
|
|
||||||
return app(WorkspaceHealthSummaryQuery::class)
|
|
||||||
->attentionNeeded($window, 10)
|
|
||||||
->contains(fn (array $summary): bool => static::canAccessNextLink($summary['next_link'], $user));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function getViewData(): array
|
|
||||||
{
|
|
||||||
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
|
|
||||||
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'windowLabel' => $windowLabel,
|
|
||||||
'rows' => app(WorkspaceHealthSummaryQuery::class)
|
|
||||||
->attentionNeeded($window, 10)
|
|
||||||
->filter(fn (array $summary): bool => $user instanceof PlatformUser && static::canAccessNextLink($summary['next_link'], $user))
|
|
||||||
->map(fn (array $summary): array => $this->presentSummary($summary)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{label: string, url: string} $nextLink
|
|
||||||
*/
|
|
||||||
private static function canAccessNextLink(array $nextLink, PlatformUser $user): bool
|
|
||||||
{
|
|
||||||
if ($user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return static::canOpenRuns($user)
|
|
||||||
&& $nextLink['url'] === SystemOperationRunLinks::index();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function canOpenRuns(PlatformUser $user): bool
|
|
||||||
{
|
|
||||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
|
||||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_name: string,
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>,
|
|
||||||
* non_ok_dimension_count: int,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* } $summary
|
|
||||||
* @return array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_label: string,
|
|
||||||
* overall: array{label: string, color: string, icon: ?string},
|
|
||||||
* dominant_copy: string,
|
|
||||||
* dominant_dimensions: list<array{label: string, color: string, icon: ?string}>,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private function presentSummary(array $summary): array
|
|
||||||
{
|
|
||||||
$dominantDimensions = collect($summary['dominant_dimension_keys'])
|
|
||||||
->take(2)
|
|
||||||
->map(function (string $dimensionKey) use ($summary): array {
|
|
||||||
$dimension = $summary['dimensions'][$dimensionKey];
|
|
||||||
$badge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $dimension['level']);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'label' => $dimension['label'],
|
|
||||||
'color' => $badge->color,
|
|
||||||
'icon' => $badge->icon,
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$overallBadge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $summary['overall_level']);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace_id' => $summary['workspace_id'],
|
|
||||||
'workspace_label' => $summary['workspace_name'],
|
|
||||||
'overall' => [
|
|
||||||
'label' => $overallBadge->label,
|
|
||||||
'color' => $overallBadge->color,
|
|
||||||
'icon' => $overallBadge->icon,
|
|
||||||
],
|
|
||||||
'dominant_copy' => implode(', ', array_column($dominantDimensions, 'label')),
|
|
||||||
'dominant_dimensions' => $dominantDimensions,
|
|
||||||
'next_link' => $summary['next_link'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\CustomerHealth;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
final class CustomerHealthDimensionCatalog
|
|
||||||
{
|
|
||||||
public const string ONBOARDING_READINESS = 'onboarding_readiness';
|
|
||||||
|
|
||||||
public const string PROVIDER_CONNECTION_HEALTH = 'provider_connection_health';
|
|
||||||
|
|
||||||
public const string OPERATIONAL_STABILITY = 'operational_stability';
|
|
||||||
|
|
||||||
public const string GOVERNANCE_PRESSURE = 'governance_pressure';
|
|
||||||
|
|
||||||
public const string REVIEW_PACK_READINESS = 'review_pack_readiness';
|
|
||||||
|
|
||||||
public const string ENGAGEMENT_FRESHNESS = 'engagement_freshness';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, array{label: string, windowed: bool}>
|
|
||||||
*/
|
|
||||||
private const DEFINITIONS = [
|
|
||||||
self::ONBOARDING_READINESS => [
|
|
||||||
'label' => 'Onboarding readiness',
|
|
||||||
'windowed' => false,
|
|
||||||
],
|
|
||||||
self::PROVIDER_CONNECTION_HEALTH => [
|
|
||||||
'label' => 'Provider connection health',
|
|
||||||
'windowed' => false,
|
|
||||||
],
|
|
||||||
self::OPERATIONAL_STABILITY => [
|
|
||||||
'label' => 'Operational stability',
|
|
||||||
'windowed' => true,
|
|
||||||
],
|
|
||||||
self::GOVERNANCE_PRESSURE => [
|
|
||||||
'label' => 'Governance pressure',
|
|
||||||
'windowed' => false,
|
|
||||||
],
|
|
||||||
self::REVIEW_PACK_READINESS => [
|
|
||||||
'label' => 'Review-pack readiness',
|
|
||||||
'windowed' => true,
|
|
||||||
],
|
|
||||||
self::ENGAGEMENT_FRESHNESS => [
|
|
||||||
'label' => 'Engagement freshness',
|
|
||||||
'windowed' => true,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function names(): array
|
|
||||||
{
|
|
||||||
return array_keys(self::DEFINITIONS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{label: string, windowed: bool}
|
|
||||||
*/
|
|
||||||
public function definition(string $dimension): array
|
|
||||||
{
|
|
||||||
$definition = self::DEFINITIONS[$dimension] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($definition)) {
|
|
||||||
throw new InvalidArgumentException("Unknown customer health dimension [{$dimension}].");
|
|
||||||
}
|
|
||||||
|
|
||||||
return $definition;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function label(string $dimension): string
|
|
||||||
{
|
|
||||||
return $this->definition($dimension)['label'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isWindowed(string $dimension): bool
|
|
||||||
{
|
|
||||||
return $this->definition($dimension)['windowed'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function visibleDimensions(): array
|
|
||||||
{
|
|
||||||
$dimensions = [];
|
|
||||||
|
|
||||||
foreach ($this->names() as $dimension) {
|
|
||||||
$dimensions[$dimension] = $this->label($dimension);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $dimensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $levels
|
|
||||||
*/
|
|
||||||
public function resolveOverallLevel(array $levels): string
|
|
||||||
{
|
|
||||||
foreach (['critical', 'warn', 'unknown'] as $level) {
|
|
||||||
if (in_array($level, $levels, true)) {
|
|
||||||
return $level;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,766 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\CustomerHealth;
|
|
||||||
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\FindingException;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ProductUsageEvent;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantOnboardingSession;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Onboarding\OnboardingLifecycleState;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
|
||||||
use App\Support\SystemConsole\StuckRunClassifier;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
|
|
||||||
final class WorkspaceHealthSummaryQuery
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly CustomerHealthDimensionCatalog $dimensionCatalog,
|
|
||||||
private readonly StuckRunClassifier $stuckRunClassifier,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_name: string,
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>,
|
|
||||||
* non_ok_dimension_count: int,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
public function summaries(SystemConsoleWindow|string|null $window = null, ?CarbonImmutable $now = null): Collection
|
|
||||||
{
|
|
||||||
$resolvedWindow = $this->resolveWindow($window);
|
|
||||||
$now ??= CarbonImmutable::now();
|
|
||||||
$startAt = $resolvedWindow->startAt($now);
|
|
||||||
|
|
||||||
$workspaces = Workspace::query()
|
|
||||||
->whereNull('archived_at')
|
|
||||||
->orderBy('name')
|
|
||||||
->orderBy('id')
|
|
||||||
->get(['id', 'name']);
|
|
||||||
|
|
||||||
if ($workspaces->isEmpty()) {
|
|
||||||
return collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceIds = $workspaces
|
|
||||||
->pluck('id')
|
|
||||||
->map(static fn (mixed $workspaceId): int => (int) $workspaceId)
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$activeTenants = Tenant::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->whereNull('deleted_at')
|
|
||||||
->where('status', '!=', Tenant::STATUS_ARCHIVED)
|
|
||||||
->orderBy('name')
|
|
||||||
->orderBy('id')
|
|
||||||
->get(['id', 'workspace_id', 'external_id', 'name', 'status']);
|
|
||||||
|
|
||||||
$tenantsByWorkspace = $activeTenants->groupBy(static fn (Tenant $tenant): int => (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
$latestOnboardingSessions = TenantOnboardingSession::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
->orderByDesc('updated_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->get(['id', 'workspace_id', 'tenant_id', 'lifecycle_state', 'updated_at', 'created_at'])
|
|
||||||
->groupBy(static fn (TenantOnboardingSession $session): int => (int) $session->workspace_id)
|
|
||||||
->map(static fn (Collection $sessions): ?TenantOnboardingSession => $sessions->first());
|
|
||||||
|
|
||||||
$providerConnectionsByWorkspace = ProviderConnection::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where('is_default', true)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
->orderByDesc('is_enabled')
|
|
||||||
->orderBy('id')
|
|
||||||
->get([
|
|
||||||
'id',
|
|
||||||
'workspace_id',
|
|
||||||
'tenant_id',
|
|
||||||
'is_enabled',
|
|
||||||
'consent_status',
|
|
||||||
'verification_status',
|
|
||||||
])
|
|
||||||
->groupBy(static fn (ProviderConnection $connection): int => (int) $connection->workspace_id);
|
|
||||||
|
|
||||||
$recentRunCounts = $this->groupedCounts(
|
|
||||||
OperationRun::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where('created_at', '>=', $startAt)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$recentFailedRunCounts = $this->groupedCounts(
|
|
||||||
OperationRun::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where('created_at', '>=', $startAt)
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Failed->value)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$recentStuckRunCounts = $this->groupedCounts(
|
|
||||||
$this->stuckRunClassifier->apply(
|
|
||||||
OperationRun::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where('created_at', '>=', $startAt)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
}),
|
|
||||||
$now,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$activeHighSeverityFindingCounts = $this->groupedCounts(
|
|
||||||
Finding::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->whereIn('severity', Finding::highSeverityValues())
|
|
||||||
->whereIn('status', Finding::openStatusesForQuery())
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$anyGovernanceFindingCounts = $this->groupedCounts(
|
|
||||||
Finding::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$overdueHighSeverityFindingCounts = $this->groupedCounts(
|
|
||||||
Finding::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->whereIn('severity', Finding::highSeverityValues())
|
|
||||||
->whereIn('status', Finding::openStatusesForQuery())
|
|
||||||
->whereNotNull('due_at')
|
|
||||||
->where('due_at', '<', $now)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$warningExceptionCounts = $this->groupedCounts(
|
|
||||||
FindingException::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$query
|
|
||||||
->whereIn('status', [
|
|
||||||
FindingException::STATUS_PENDING,
|
|
||||||
FindingException::STATUS_EXPIRING,
|
|
||||||
])
|
|
||||||
->orWhere('current_validity_state', FindingException::VALIDITY_EXPIRING);
|
|
||||||
})
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$criticalExceptionCounts = $this->groupedCounts(
|
|
||||||
FindingException::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$query
|
|
||||||
->whereIn('status', [
|
|
||||||
FindingException::STATUS_EXPIRED,
|
|
||||||
FindingException::STATUS_REVOKED,
|
|
||||||
])
|
|
||||||
->orWhereIn('current_validity_state', [
|
|
||||||
FindingException::VALIDITY_EXPIRED,
|
|
||||||
FindingException::VALIDITY_REVOKED,
|
|
||||||
FindingException::VALIDITY_REJECTED,
|
|
||||||
FindingException::VALIDITY_MISSING_SUPPORT,
|
|
||||||
]);
|
|
||||||
})
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$reviewPackRequestCounts = $this->groupedCounts(
|
|
||||||
ProductUsageEvent::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where('event_name', ProductUsageEventCatalog::REVIEW_PACK_REQUESTED)
|
|
||||||
->where('occurred_at', '>=', $startAt)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$recentReviewPacks = ReviewPack::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where('created_at', '>=', $startAt)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->get(['id', 'workspace_id', 'tenant_id', 'status', 'expires_at', 'created_at'])
|
|
||||||
->groupBy(static fn (ReviewPack $reviewPack): int => (int) $reviewPack->workspace_id)
|
|
||||||
->map(static fn (Collection $reviewPacks): ?ReviewPack => $reviewPacks->first());
|
|
||||||
|
|
||||||
$recentUsageEventCounts = $this->groupedCounts(
|
|
||||||
ProductUsageEvent::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where('occurred_at', '>=', $startAt)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$historicalUsageEventCounts = $this->groupedCounts(
|
|
||||||
ProductUsageEvent::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return $workspaces
|
|
||||||
->map(function (Workspace $workspace) use (
|
|
||||||
$tenantsByWorkspace,
|
|
||||||
$latestOnboardingSessions,
|
|
||||||
$providerConnectionsByWorkspace,
|
|
||||||
$recentRunCounts,
|
|
||||||
$recentFailedRunCounts,
|
|
||||||
$recentStuckRunCounts,
|
|
||||||
$activeHighSeverityFindingCounts,
|
|
||||||
$anyGovernanceFindingCounts,
|
|
||||||
$overdueHighSeverityFindingCounts,
|
|
||||||
$warningExceptionCounts,
|
|
||||||
$criticalExceptionCounts,
|
|
||||||
$reviewPackRequestCounts,
|
|
||||||
$recentReviewPacks,
|
|
||||||
$recentUsageEventCounts,
|
|
||||||
$historicalUsageEventCounts,
|
|
||||||
$resolvedWindow,
|
|
||||||
$now,
|
|
||||||
): array {
|
|
||||||
$workspaceId = (int) $workspace->getKey();
|
|
||||||
/** @var Collection<int, Tenant> $workspaceTenants */
|
|
||||||
$workspaceTenants = $tenantsByWorkspace->get($workspaceId, collect());
|
|
||||||
/** @var TenantOnboardingSession|null $latestOnboardingSession */
|
|
||||||
$latestOnboardingSession = $latestOnboardingSessions->get($workspaceId);
|
|
||||||
/** @var Collection<int, ProviderConnection> $providerConnections */
|
|
||||||
$providerConnections = $providerConnectionsByWorkspace->get($workspaceId, collect());
|
|
||||||
/** @var ReviewPack|null $latestReviewPack */
|
|
||||||
$latestReviewPack = $recentReviewPacks->get($workspaceId);
|
|
||||||
|
|
||||||
$dimensions = $this->buildDimensions(
|
|
||||||
tenants: $workspaceTenants,
|
|
||||||
latestOnboardingSession: $latestOnboardingSession,
|
|
||||||
providerConnections: $providerConnections,
|
|
||||||
recentRunCount: $this->countForWorkspace($recentRunCounts, $workspaceId),
|
|
||||||
recentFailedRunCount: $this->countForWorkspace($recentFailedRunCounts, $workspaceId),
|
|
||||||
recentStuckRunCount: $this->countForWorkspace($recentStuckRunCounts, $workspaceId),
|
|
||||||
activeHighSeverityFindingCount: $this->countForWorkspace($activeHighSeverityFindingCounts, $workspaceId),
|
|
||||||
anyGovernanceFindingCount: $this->countForWorkspace($anyGovernanceFindingCounts, $workspaceId),
|
|
||||||
overdueHighSeverityFindingCount: $this->countForWorkspace($overdueHighSeverityFindingCounts, $workspaceId),
|
|
||||||
warningExceptionCount: $this->countForWorkspace($warningExceptionCounts, $workspaceId),
|
|
||||||
criticalExceptionCount: $this->countForWorkspace($criticalExceptionCounts, $workspaceId),
|
|
||||||
reviewPackRequestCount: $this->countForWorkspace($reviewPackRequestCounts, $workspaceId),
|
|
||||||
latestReviewPack: $latestReviewPack,
|
|
||||||
recentUsageEventCount: $this->countForWorkspace($recentUsageEventCounts, $workspaceId),
|
|
||||||
historicalUsageEventCount: $this->countForWorkspace($historicalUsageEventCounts, $workspaceId),
|
|
||||||
now: $now,
|
|
||||||
);
|
|
||||||
|
|
||||||
$overallLevel = $this->dimensionCatalog->resolveOverallLevel(
|
|
||||||
array_map(static fn (array $dimension): string => $dimension['level'], $dimensions),
|
|
||||||
);
|
|
||||||
|
|
||||||
$dominantDimensionKeys = $this->dominantDimensionKeys($dimensions);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'workspace_name' => (string) $workspace->name,
|
|
||||||
'overall_level' => $overallLevel,
|
|
||||||
'dimensions' => $dimensions,
|
|
||||||
'dominant_dimension_keys' => $dominantDimensionKeys,
|
|
||||||
'non_ok_dimension_count' => count(array_filter(
|
|
||||||
$dimensions,
|
|
||||||
static fn (array $dimension): bool => $dimension['level'] !== 'ok',
|
|
||||||
)),
|
|
||||||
'next_link' => $this->nextLink(
|
|
||||||
workspace: $workspace,
|
|
||||||
tenants: $workspaceTenants,
|
|
||||||
dominantDimensionKeys: $dominantDimensionKeys,
|
|
||||||
window: $resolvedWindow,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_name: string,
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>,
|
|
||||||
* non_ok_dimension_count: int,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* }|null
|
|
||||||
*/
|
|
||||||
public function summaryForWorkspace(Workspace|int $workspace, SystemConsoleWindow|string|null $window = null, ?CarbonImmutable $now = null): ?array
|
|
||||||
{
|
|
||||||
$workspaceId = $workspace instanceof Workspace ? (int) $workspace->getKey() : (int) $workspace;
|
|
||||||
|
|
||||||
/** @var array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_name: string,
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>,
|
|
||||||
* non_ok_dimension_count: int,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* }|null $summary
|
|
||||||
*/
|
|
||||||
$summary = $this->summaries($window, $now)->firstWhere('workspace_id', $workspaceId);
|
|
||||||
|
|
||||||
return $summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{ok: int, warn: int, critical: int, unknown: int}
|
|
||||||
*/
|
|
||||||
public function healthCounts(SystemConsoleWindow|string|null $window = null, ?CarbonImmutable $now = null): array
|
|
||||||
{
|
|
||||||
$counts = [
|
|
||||||
'ok' => 0,
|
|
||||||
'warn' => 0,
|
|
||||||
'critical' => 0,
|
|
||||||
'unknown' => 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($this->summaries($window, $now) as $summary) {
|
|
||||||
$counts[$summary['overall_level']]++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_name: string,
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>,
|
|
||||||
* non_ok_dimension_count: int,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
public function attentionNeeded(SystemConsoleWindow|string|null $window = null, int $limit = 10, ?CarbonImmutable $now = null): Collection
|
|
||||||
{
|
|
||||||
return $this->summaries($window, $now)
|
|
||||||
->filter(static fn (array $summary): bool => $summary['overall_level'] !== 'ok')
|
|
||||||
->sort(function (array $left, array $right): int {
|
|
||||||
$severityComparison = $this->levelRank($right['overall_level']) <=> $this->levelRank($left['overall_level']);
|
|
||||||
|
|
||||||
if ($severityComparison !== 0) {
|
|
||||||
return $severityComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
$nonOkComparison = $right['non_ok_dimension_count'] <=> $left['non_ok_dimension_count'];
|
|
||||||
|
|
||||||
if ($nonOkComparison !== 0) {
|
|
||||||
return $nonOkComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
$nameComparison = strcasecmp($left['workspace_name'], $right['workspace_name']);
|
|
||||||
|
|
||||||
if ($nameComparison !== 0) {
|
|
||||||
return $nameComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $left['workspace_id'] <=> $right['workspace_id'];
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->take(max(1, $limit));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveWindow(SystemConsoleWindow|string|null $window): SystemConsoleWindow
|
|
||||||
{
|
|
||||||
if ($window instanceof SystemConsoleWindow) {
|
|
||||||
return $window;
|
|
||||||
}
|
|
||||||
|
|
||||||
return SystemConsoleWindow::fromNullable(is_string($window) ? $window : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, Tenant> $tenants
|
|
||||||
* @param Collection<int, ProviderConnection> $providerConnections
|
|
||||||
* @return array<string, array{label: string, level: string, windowed: bool}>
|
|
||||||
*/
|
|
||||||
private function buildDimensions(
|
|
||||||
Collection $tenants,
|
|
||||||
?TenantOnboardingSession $latestOnboardingSession,
|
|
||||||
Collection $providerConnections,
|
|
||||||
int $recentRunCount,
|
|
||||||
int $recentFailedRunCount,
|
|
||||||
int $recentStuckRunCount,
|
|
||||||
int $activeHighSeverityFindingCount,
|
|
||||||
int $anyGovernanceFindingCount,
|
|
||||||
int $overdueHighSeverityFindingCount,
|
|
||||||
int $warningExceptionCount,
|
|
||||||
int $criticalExceptionCount,
|
|
||||||
int $reviewPackRequestCount,
|
|
||||||
?ReviewPack $latestReviewPack,
|
|
||||||
int $recentUsageEventCount,
|
|
||||||
int $historicalUsageEventCount,
|
|
||||||
CarbonImmutable $now,
|
|
||||||
): array {
|
|
||||||
$levels = [
|
|
||||||
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $this->onboardingReadinessLevel($tenants, $latestOnboardingSession),
|
|
||||||
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $this->providerConnectionHealthLevel($providerConnections),
|
|
||||||
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => $this->operationalStabilityLevel($recentRunCount, $recentFailedRunCount, $recentStuckRunCount),
|
|
||||||
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $this->governancePressureLevel(
|
|
||||||
activeHighSeverityFindingCount: $activeHighSeverityFindingCount,
|
|
||||||
anyGovernanceFindingCount: $anyGovernanceFindingCount,
|
|
||||||
overdueHighSeverityFindingCount: $overdueHighSeverityFindingCount,
|
|
||||||
warningExceptionCount: $warningExceptionCount,
|
|
||||||
criticalExceptionCount: $criticalExceptionCount,
|
|
||||||
),
|
|
||||||
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => $this->reviewPackReadinessLevel($reviewPackRequestCount, $latestReviewPack, $now),
|
|
||||||
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $this->engagementFreshnessLevel($recentUsageEventCount, $historicalUsageEventCount),
|
|
||||||
];
|
|
||||||
|
|
||||||
$dimensions = [];
|
|
||||||
|
|
||||||
foreach ($this->dimensionCatalog->visibleDimensions() as $dimensionKey => $label) {
|
|
||||||
$dimensions[$dimensionKey] = [
|
|
||||||
'label' => $label,
|
|
||||||
'level' => $levels[$dimensionKey],
|
|
||||||
'windowed' => $this->dimensionCatalog->isWindowed($dimensionKey),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $dimensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, Tenant> $tenants
|
|
||||||
*/
|
|
||||||
private function onboardingReadinessLevel(Collection $tenants, ?TenantOnboardingSession $latestOnboardingSession): string
|
|
||||||
{
|
|
||||||
if ($latestOnboardingSession instanceof TenantOnboardingSession) {
|
|
||||||
return match ($latestOnboardingSession->lifecycleState()) {
|
|
||||||
OnboardingLifecycleState::ReadyForActivation,
|
|
||||||
OnboardingLifecycleState::Completed => 'ok',
|
|
||||||
OnboardingLifecycleState::Cancelled => 'critical',
|
|
||||||
OnboardingLifecycleState::Draft,
|
|
||||||
OnboardingLifecycleState::Verifying,
|
|
||||||
OnboardingLifecycleState::ActionRequired,
|
|
||||||
OnboardingLifecycleState::Bootstrapping => 'warn',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenants->isEmpty()) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenants->contains(static fn (Tenant $tenant): bool => (string) $tenant->status === Tenant::STATUS_ACTIVE)) {
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, ProviderConnection> $providerConnections
|
|
||||||
*/
|
|
||||||
private function providerConnectionHealthLevel(Collection $providerConnections): string
|
|
||||||
{
|
|
||||||
if ($providerConnections->isEmpty()) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($providerConnections->contains(function (ProviderConnection $connection): bool {
|
|
||||||
if (! $connection->is_enabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$consentStatus = $this->normalizeBackedEnumValue($connection->consent_status);
|
|
||||||
$verificationStatus = $this->normalizeBackedEnumValue($connection->verification_status);
|
|
||||||
|
|
||||||
return in_array($consentStatus, [
|
|
||||||
ProviderConsentStatus::Required->value,
|
|
||||||
ProviderConsentStatus::Failed->value,
|
|
||||||
ProviderConsentStatus::Revoked->value,
|
|
||||||
], true) || in_array($verificationStatus, [
|
|
||||||
ProviderVerificationStatus::Blocked->value,
|
|
||||||
ProviderVerificationStatus::Error->value,
|
|
||||||
], true);
|
|
||||||
})) {
|
|
||||||
return 'critical';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($providerConnections->contains(function (ProviderConnection $connection): bool {
|
|
||||||
if (! $connection->is_enabled) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$consentStatus = $this->normalizeBackedEnumValue($connection->consent_status);
|
|
||||||
$verificationStatus = $this->normalizeBackedEnumValue($connection->verification_status);
|
|
||||||
|
|
||||||
return in_array($consentStatus, [
|
|
||||||
ProviderConsentStatus::Unknown->value,
|
|
||||||
], true) || in_array($verificationStatus, [
|
|
||||||
ProviderVerificationStatus::Unknown->value,
|
|
||||||
ProviderVerificationStatus::Pending->value,
|
|
||||||
ProviderVerificationStatus::Degraded->value,
|
|
||||||
], true);
|
|
||||||
})) {
|
|
||||||
return 'warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($providerConnections->contains(function (ProviderConnection $connection): bool {
|
|
||||||
return $connection->is_enabled
|
|
||||||
&& $this->normalizeBackedEnumValue($connection->consent_status) === ProviderConsentStatus::Granted->value
|
|
||||||
&& $this->normalizeBackedEnumValue($connection->verification_status) === ProviderVerificationStatus::Healthy->value;
|
|
||||||
})) {
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function operationalStabilityLevel(int $recentRunCount, int $recentFailedRunCount, int $recentStuckRunCount): string
|
|
||||||
{
|
|
||||||
if ($recentFailedRunCount > 0 || $recentStuckRunCount > 0) {
|
|
||||||
return 'critical';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($recentRunCount > 0) {
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function governancePressureLevel(
|
|
||||||
int $activeHighSeverityFindingCount,
|
|
||||||
int $anyGovernanceFindingCount,
|
|
||||||
int $overdueHighSeverityFindingCount,
|
|
||||||
int $warningExceptionCount,
|
|
||||||
int $criticalExceptionCount,
|
|
||||||
): string {
|
|
||||||
if ($overdueHighSeverityFindingCount > 0 || $criticalExceptionCount > 0) {
|
|
||||||
return 'critical';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($activeHighSeverityFindingCount > 0 || $warningExceptionCount > 0) {
|
|
||||||
return 'warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($anyGovernanceFindingCount === 0 && $warningExceptionCount === 0 && $criticalExceptionCount === 0) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function reviewPackReadinessLevel(int $reviewPackRequestCount, ?ReviewPack $latestReviewPack, CarbonImmutable $now): string
|
|
||||||
{
|
|
||||||
if ($reviewPackRequestCount === 0 && ! $latestReviewPack instanceof ReviewPack) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $latestReviewPack instanceof ReviewPack) {
|
|
||||||
return 'warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(string) $latestReviewPack->status === ReviewPack::STATUS_READY
|
|
||||||
&& (! $latestReviewPack->expires_at instanceof CarbonImmutable || $latestReviewPack->expires_at->gt($now))
|
|
||||||
) {
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
in_array((string) $latestReviewPack->status, [
|
|
||||||
ReviewPack::STATUS_FAILED,
|
|
||||||
ReviewPack::STATUS_EXPIRED,
|
|
||||||
], true)
|
|
||||||
|| ($latestReviewPack->expires_at !== null && $latestReviewPack->expires_at->lte($now))
|
|
||||||
) {
|
|
||||||
return 'critical';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function engagementFreshnessLevel(int $recentUsageEventCount, int $historicalUsageEventCount): string
|
|
||||||
{
|
|
||||||
if ($recentUsageEventCount > 0) {
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($historicalUsageEventCount > 0) {
|
|
||||||
return 'warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, array{label: string, level: string, windowed: bool}> $dimensions
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private function dominantDimensionKeys(array $dimensions): array
|
|
||||||
{
|
|
||||||
$catalogOrder = array_flip($this->dimensionCatalog->names());
|
|
||||||
|
|
||||||
return collect($dimensions)
|
|
||||||
->reject(static fn (array $dimension): bool => $dimension['level'] === 'ok')
|
|
||||||
->keys()
|
|
||||||
->sort(function (string $left, string $right) use ($dimensions, $catalogOrder): int {
|
|
||||||
$severityComparison = $this->levelRank($dimensions[$right]['level']) <=> $this->levelRank($dimensions[$left]['level']);
|
|
||||||
|
|
||||||
if ($severityComparison !== 0) {
|
|
||||||
return $severityComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ($catalogOrder[$left] ?? PHP_INT_MAX) <=> ($catalogOrder[$right] ?? PHP_INT_MAX);
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, Tenant> $tenants
|
|
||||||
* @param list<string> $dominantDimensionKeys
|
|
||||||
* @return array{label: string, url: string}
|
|
||||||
*/
|
|
||||||
private function nextLink(Workspace $workspace, Collection $tenants, array $dominantDimensionKeys, SystemConsoleWindow $window): array
|
|
||||||
{
|
|
||||||
$dominantDimension = $dominantDimensionKeys[0] ?? null;
|
|
||||||
|
|
||||||
if ($dominantDimension === CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY && $this->canOpenRunsLink()) {
|
|
||||||
return [
|
|
||||||
'label' => 'Open runs',
|
|
||||||
'url' => SystemOperationRunLinks::index(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var Tenant|null $tenant */
|
|
||||||
$tenant = $tenants->first();
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
'label' => 'Review health details',
|
|
||||||
'url' => $this->withWindowQuery(SystemDirectoryLinks::tenantDetail($tenant), $window),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'label' => 'Review health details',
|
|
||||||
'url' => $this->withWindowQuery(SystemDirectoryLinks::workspaceDetail($workspace), $window),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function withWindowQuery(string $url, SystemConsoleWindow $window): string
|
|
||||||
{
|
|
||||||
$separator = str_contains($url, '?') ? '&' : '?';
|
|
||||||
|
|
||||||
return $url.$separator.http_build_query(['window' => $window->value]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function canOpenRunsLink(): bool
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
if (! $user instanceof PlatformUser) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
|
||||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<OperationRun>|Builder<ProductUsageEvent>|Builder<ReviewPack>|Builder<Finding>|Builder<FindingException> $query
|
|
||||||
* @return array<int, int>
|
|
||||||
*/
|
|
||||||
private function groupedCounts(Builder $query): array
|
|
||||||
{
|
|
||||||
return $query
|
|
||||||
->selectRaw('workspace_id, COUNT(*) as aggregate')
|
|
||||||
->groupBy('workspace_id')
|
|
||||||
->pluck('aggregate', 'workspace_id')
|
|
||||||
->mapWithKeys(static fn (mixed $count, mixed $workspaceId): array => [
|
|
||||||
(int) $workspaceId => (int) $count,
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, int> $counts
|
|
||||||
*/
|
|
||||||
private function countForWorkspace(array $counts, int $workspaceId): int
|
|
||||||
{
|
|
||||||
return (int) ($counts[$workspaceId] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function levelRank(string $level): int
|
|
||||||
{
|
|
||||||
return match ($level) {
|
|
||||||
'critical' => 4,
|
|
||||||
'warn' => 3,
|
|
||||||
'unknown' => 2,
|
|
||||||
default => 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeBackedEnumValue(mixed $value): string
|
|
||||||
{
|
|
||||||
if (is_object($value) && property_exists($value, 'value')) {
|
|
||||||
return (string) $value->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (string) $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function constrainToActiveTenantTruth(Builder $query): void
|
|
||||||
{
|
|
||||||
$query
|
|
||||||
->whereNull('tenant_id')
|
|
||||||
->orWhereHas('tenant', function (Builder $tenantQuery): void {
|
|
||||||
$tenantQuery
|
|
||||||
->whereNull('deleted_at')
|
|
||||||
->where('status', '!=', Tenant::STATUS_ARCHIVED);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,263 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\ProductKnowledge;
|
|
||||||
|
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
final class ContextualHelpCatalog
|
|
||||||
{
|
|
||||||
public const string ADMIN_CONSENT_REQUIRED = 'admin-consent-required';
|
|
||||||
|
|
||||||
public const string REQUIRED_PERMISSIONS_MISSING = 'required-permissions-missing';
|
|
||||||
|
|
||||||
public const string CONNECTION_UNHEALTHY = 'connection-unhealthy';
|
|
||||||
|
|
||||||
public const string VERIFICATION_STALE = 'verification-stale';
|
|
||||||
|
|
||||||
public const string VERIFICATION_FAILED = 'verification-failed';
|
|
||||||
|
|
||||||
public const string DIAGNOSTIC_EVIDENCE_INCOMPLETE = 'diagnostic-evidence-incomplete';
|
|
||||||
|
|
||||||
public const string RETRYABLE_PROVIDER_FAILURE = 'retryable-provider-failure';
|
|
||||||
|
|
||||||
public const string MANUAL_HANDOFF_REQUIRED = 'manual-handoff-required';
|
|
||||||
|
|
||||||
public const string LINK_RESOLVER_ADMIN_CONSENT_PRIMARY = 'admin_consent_primary';
|
|
||||||
|
|
||||||
public const string LINK_RESOLVER_REQUIRED_PERMISSIONS = 'required_permissions';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function keys(): array
|
|
||||||
{
|
|
||||||
return array_keys($this->definitions());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array{
|
|
||||||
* topic_key: string,
|
|
||||||
* surface_families: list<string>,
|
|
||||||
* headline: string,
|
|
||||||
* short_explanation: string,
|
|
||||||
* troubleshooting_steps: list<string>,
|
|
||||||
* safe_next_action: string,
|
|
||||||
* glossary_terms: list<string>,
|
|
||||||
* docs_links: list<array{
|
|
||||||
* label: string,
|
|
||||||
* kind: string,
|
|
||||||
* url?: string,
|
|
||||||
* resolver?: string
|
|
||||||
* }>
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
public function definitions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
self::ADMIN_CONSENT_REQUIRED => [
|
|
||||||
'topic_key' => self::ADMIN_CONSENT_REQUIRED,
|
|
||||||
'surface_families' => ['onboarding', 'support_diagnostics'],
|
|
||||||
'headline' => 'Admin consent required',
|
|
||||||
'short_explanation' => 'This workflow is blocked until admin consent is granted for the current provider connection.',
|
|
||||||
'troubleshooting_steps' => [
|
|
||||||
'Grant admin consent for the current provider connection.',
|
|
||||||
'Re-run verification or reopen support diagnostics after consent completes.',
|
|
||||||
],
|
|
||||||
'safe_next_action' => 'Grant admin consent and re-run verification.',
|
|
||||||
'glossary_terms' => ['tenant', 'workspace', 'provider connection'],
|
|
||||||
'docs_links' => [
|
|
||||||
[
|
|
||||||
'label' => 'Grant admin consent',
|
|
||||||
'kind' => 'action',
|
|
||||||
'resolver' => self::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => 'Admin consent guide',
|
|
||||||
'kind' => 'docs',
|
|
||||||
'url' => RequiredPermissionsLinks::adminConsentGuideUrl(),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
self::REQUIRED_PERMISSIONS_MISSING => [
|
|
||||||
'topic_key' => self::REQUIRED_PERMISSIONS_MISSING,
|
|
||||||
'surface_families' => ['onboarding', 'support_diagnostics'],
|
|
||||||
'headline' => 'Required permissions missing',
|
|
||||||
'short_explanation' => 'The provider app is missing one or more required permissions for the current workflow.',
|
|
||||||
'troubleshooting_steps' => [
|
|
||||||
'Review the required permissions matrix for the current tenant.',
|
|
||||||
'Refresh verification after the missing permissions are granted.',
|
|
||||||
],
|
|
||||||
'safe_next_action' => 'Open required permissions and confirm the missing grants.',
|
|
||||||
'glossary_terms' => ['tenant', 'workspace', 'provider connection'],
|
|
||||||
'docs_links' => [
|
|
||||||
[
|
|
||||||
'label' => 'Open required permissions',
|
|
||||||
'kind' => 'action',
|
|
||||||
'resolver' => self::LINK_RESOLVER_REQUIRED_PERMISSIONS,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
self::CONNECTION_UNHEALTHY => [
|
|
||||||
'topic_key' => self::CONNECTION_UNHEALTHY,
|
|
||||||
'surface_families' => ['onboarding', 'support_diagnostics'],
|
|
||||||
'headline' => 'Provider connection needs review',
|
|
||||||
'short_explanation' => 'The provider connection is degraded or unavailable, so the current result cannot be treated as healthy.',
|
|
||||||
'troubleshooting_steps' => [
|
|
||||||
'Review the latest provider connection health signal.',
|
|
||||||
'Retry the workflow after the provider connection is healthy again.',
|
|
||||||
],
|
|
||||||
'safe_next_action' => 'Review the provider connection before retrying.',
|
|
||||||
'glossary_terms' => ['tenant', 'workspace', 'provider connection'],
|
|
||||||
'docs_links' => [],
|
|
||||||
],
|
|
||||||
self::VERIFICATION_STALE => [
|
|
||||||
'topic_key' => self::VERIFICATION_STALE,
|
|
||||||
'surface_families' => ['onboarding'],
|
|
||||||
'headline' => 'Verification result is stale',
|
|
||||||
'short_explanation' => 'The most recent verification result is too old or mismatched to trust for the current onboarding decision.',
|
|
||||||
'troubleshooting_steps' => [
|
|
||||||
'Run verification again for the currently selected provider connection.',
|
|
||||||
'Use the refreshed result before continuing onboarding.',
|
|
||||||
],
|
|
||||||
'safe_next_action' => 'Refresh verification before continuing onboarding.',
|
|
||||||
'glossary_terms' => ['tenant', 'workspace', 'verification'],
|
|
||||||
'docs_links' => [],
|
|
||||||
],
|
|
||||||
self::VERIFICATION_FAILED => [
|
|
||||||
'topic_key' => self::VERIFICATION_FAILED,
|
|
||||||
'surface_families' => ['onboarding', 'support_diagnostics'],
|
|
||||||
'headline' => 'Verification failed',
|
|
||||||
'short_explanation' => 'The latest verification run did not produce a decision-grade result for the current tenant context.',
|
|
||||||
'troubleshooting_steps' => [
|
|
||||||
'Review the blocking reason before retrying verification.',
|
|
||||||
'Confirm the prerequisite is fixed, then run verification again.',
|
|
||||||
],
|
|
||||||
'safe_next_action' => 'Review the blocking reason and retry verification.',
|
|
||||||
'glossary_terms' => ['tenant', 'workspace', 'verification'],
|
|
||||||
'docs_links' => [],
|
|
||||||
],
|
|
||||||
self::DIAGNOSTIC_EVIDENCE_INCOMPLETE => [
|
|
||||||
'topic_key' => self::DIAGNOSTIC_EVIDENCE_INCOMPLETE,
|
|
||||||
'surface_families' => ['support_diagnostics'],
|
|
||||||
'headline' => 'Diagnostic evidence is incomplete',
|
|
||||||
'short_explanation' => 'Support diagnostics can summarize the issue, but the available evidence is not complete enough for a final conclusion.',
|
|
||||||
'troubleshooting_steps' => [
|
|
||||||
'Review the available evidence and supporting references in the current support context.',
|
|
||||||
'Collect a fresh verification or operation result before making a final decision.',
|
|
||||||
],
|
|
||||||
'safe_next_action' => 'Collect a fresher or more complete diagnostic signal.',
|
|
||||||
'glossary_terms' => ['support diagnostics', 'evidence', 'workspace'],
|
|
||||||
'docs_links' => [],
|
|
||||||
],
|
|
||||||
self::RETRYABLE_PROVIDER_FAILURE => [
|
|
||||||
'topic_key' => self::RETRYABLE_PROVIDER_FAILURE,
|
|
||||||
'surface_families' => ['support_diagnostics'],
|
|
||||||
'headline' => 'Provider failure looks retryable',
|
|
||||||
'short_explanation' => 'The current provider issue appears temporary, so the next safe step is to retry once the dependency recovers.',
|
|
||||||
'troubleshooting_steps' => [
|
|
||||||
'Confirm the provider dependency has recovered.',
|
|
||||||
'Retry the workflow after the provider-side issue clears.',
|
|
||||||
],
|
|
||||||
'safe_next_action' => 'Retry after the provider dependency recovers.',
|
|
||||||
'glossary_terms' => ['support diagnostics', 'provider connection', 'workspace'],
|
|
||||||
'docs_links' => [],
|
|
||||||
],
|
|
||||||
self::MANUAL_HANDOFF_REQUIRED => [
|
|
||||||
'topic_key' => self::MANUAL_HANDOFF_REQUIRED,
|
|
||||||
'surface_families' => ['support_diagnostics'],
|
|
||||||
'headline' => 'Manual support handoff required',
|
|
||||||
'short_explanation' => 'TenantPilot can summarize the current issue, but a human support handoff is still required for the next step.',
|
|
||||||
'troubleshooting_steps' => [
|
|
||||||
'Review the current references before handing off the case.',
|
|
||||||
'Capture the safe next step and the supporting evidence for the receiving operator.',
|
|
||||||
],
|
|
||||||
'safe_next_action' => 'Hand off the case with the current diagnostic summary and supporting references.',
|
|
||||||
'glossary_terms' => ['support diagnostics', 'tenant', 'workspace'],
|
|
||||||
'docs_links' => [],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* topic_key: string,
|
|
||||||
* surface_families: list<string>,
|
|
||||||
* headline: string,
|
|
||||||
* short_explanation: string,
|
|
||||||
* troubleshooting_steps: list<string>,
|
|
||||||
* safe_next_action: string,
|
|
||||||
* glossary_terms: list<string>,
|
|
||||||
* docs_links: list<array{
|
|
||||||
* label: string,
|
|
||||||
* kind: string,
|
|
||||||
* url?: string,
|
|
||||||
* resolver?: string
|
|
||||||
* }>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function definition(string $topicKey): array
|
|
||||||
{
|
|
||||||
$definition = $this->definitions()[trim($topicKey)] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($definition)) {
|
|
||||||
throw new InvalidArgumentException('Unknown contextual help topic');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $definition;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* version: int,
|
|
||||||
* topic_count: int,
|
|
||||||
* topics: list<array{
|
|
||||||
* topic_key: string,
|
|
||||||
* surface_families: list<string>,
|
|
||||||
* headline: string,
|
|
||||||
* short_explanation: string,
|
|
||||||
* troubleshooting_steps: list<string>,
|
|
||||||
* safe_next_action: string,
|
|
||||||
* glossary_terms: list<string>,
|
|
||||||
* docs_links: list<array{
|
|
||||||
* label: string,
|
|
||||||
* kind: string,
|
|
||||||
* url: ?string,
|
|
||||||
* resolver: ?string
|
|
||||||
* }>
|
|
||||||
* }>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function knowledgeSource(): array
|
|
||||||
{
|
|
||||||
$topics = array_values(array_map(
|
|
||||||
fn (array $definition): array => [
|
|
||||||
'topic_key' => $definition['topic_key'],
|
|
||||||
'surface_families' => $definition['surface_families'],
|
|
||||||
'headline' => $definition['headline'],
|
|
||||||
'short_explanation' => $definition['short_explanation'],
|
|
||||||
'troubleshooting_steps' => $definition['troubleshooting_steps'],
|
|
||||||
'safe_next_action' => $definition['safe_next_action'],
|
|
||||||
'glossary_terms' => $definition['glossary_terms'],
|
|
||||||
'docs_links' => array_values(array_map(
|
|
||||||
static fn (array $link): array => [
|
|
||||||
'label' => (string) $link['label'],
|
|
||||||
'kind' => (string) ($link['kind'] ?? 'url'),
|
|
||||||
'url' => isset($link['url']) && is_string($link['url']) ? $link['url'] : null,
|
|
||||||
'resolver' => isset($link['resolver']) && is_string($link['resolver']) ? $link['resolver'] : null,
|
|
||||||
],
|
|
||||||
$definition['docs_links'],
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
$this->definitions(),
|
|
||||||
));
|
|
||||||
|
|
||||||
return [
|
|
||||||
'version' => 1,
|
|
||||||
'topic_count' => count($topics),
|
|
||||||
'topics' => $topics,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,427 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\ProductKnowledge;
|
|
||||||
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Governance\PlatformVocabularyGlossary;
|
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
final class ContextualHelpResolver
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly ContextualHelpCatalog $catalog,
|
|
||||||
private readonly PlatformVocabularyGlossary $glossary,
|
|
||||||
private readonly ReasonPresenter $reasonPresenter,
|
|
||||||
private readonly OperatorExplanationBuilder $operatorExplanationBuilder,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @return array{
|
|
||||||
* topic_key: string,
|
|
||||||
* surface_families: list<string>,
|
|
||||||
* headline: string,
|
|
||||||
* short_explanation: string,
|
|
||||||
* troubleshooting_steps: list<string>,
|
|
||||||
* safe_next_action: string,
|
|
||||||
* docs_links: list<array{
|
|
||||||
* label: string,
|
|
||||||
* kind: string,
|
|
||||||
* url: ?string,
|
|
||||||
* resolver: ?string
|
|
||||||
* }>,
|
|
||||||
* canonical_terms: list<string>,
|
|
||||||
* reason_label: ?string,
|
|
||||||
* diagnostic_code: ?string,
|
|
||||||
* operator_summary: ?array{
|
|
||||||
* primaryReason: string,
|
|
||||||
* nextActionText: string,
|
|
||||||
* diagnosticsAvailable: bool,
|
|
||||||
* diagnosticsSummary: ?string
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function resolve(string $topicKey, array $context = []): array
|
|
||||||
{
|
|
||||||
$definition = $this->catalog->definition($topicKey);
|
|
||||||
$reasonEnvelope = $this->providerReasonEnvelope($context);
|
|
||||||
$operatorSummary = $this->operatorSummary($context);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'topic_key' => $definition['topic_key'],
|
|
||||||
'surface_families' => $definition['surface_families'],
|
|
||||||
'headline' => $this->firstNonEmpty(
|
|
||||||
$this->stringOrNull($context['headline'] ?? null),
|
|
||||||
$definition['headline'],
|
|
||||||
),
|
|
||||||
'short_explanation' => $this->firstNonEmpty(
|
|
||||||
$this->stringOrNull($context['short_explanation'] ?? null),
|
|
||||||
$this->reasonPresenter->shortExplanation($reasonEnvelope),
|
|
||||||
$definition['short_explanation'],
|
|
||||||
),
|
|
||||||
'troubleshooting_steps' => $this->troubleshootingSteps($definition['troubleshooting_steps'], $context),
|
|
||||||
'safe_next_action' => $this->firstNonEmpty(
|
|
||||||
$this->stringOrNull($context['safe_next_action'] ?? null),
|
|
||||||
$operatorSummary['nextActionText'] ?? null,
|
|
||||||
$definition['safe_next_action'],
|
|
||||||
),
|
|
||||||
'docs_links' => $this->resolveLinks($definition['docs_links'], $context),
|
|
||||||
'canonical_terms' => $this->canonicalTerms($definition['glossary_terms']),
|
|
||||||
'reason_label' => $this->reasonPresenter->primaryLabel($reasonEnvelope),
|
|
||||||
'diagnostic_code' => $this->reasonPresenter->diagnosticCode($reasonEnvelope),
|
|
||||||
'operator_summary' => $operatorSummary,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @return array{
|
|
||||||
* topic_key: string,
|
|
||||||
* surface_families: list<string>,
|
|
||||||
* headline: string,
|
|
||||||
* short_explanation: string,
|
|
||||||
* troubleshooting_steps: list<string>,
|
|
||||||
* safe_next_action: string,
|
|
||||||
* docs_links: list<array{
|
|
||||||
* label: string,
|
|
||||||
* kind: string,
|
|
||||||
* url: ?string,
|
|
||||||
* resolver: ?string
|
|
||||||
* }>,
|
|
||||||
* canonical_terms: list<string>,
|
|
||||||
* reason_label: ?string,
|
|
||||||
* diagnostic_code: ?string,
|
|
||||||
* operator_summary: ?array{
|
|
||||||
* primaryReason: string,
|
|
||||||
* nextActionText: string,
|
|
||||||
* diagnosticsAvailable: bool,
|
|
||||||
* diagnosticsSummary: ?string
|
|
||||||
* }
|
|
||||||
* }|null
|
|
||||||
*/
|
|
||||||
public function tryResolve(?string $topicKey, array $context = []): ?array
|
|
||||||
{
|
|
||||||
if (! is_string($topicKey) || trim($topicKey) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return $this->resolve($topicKey, $context);
|
|
||||||
} catch (InvalidArgumentException) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* version: int,
|
|
||||||
* topic_count: int,
|
|
||||||
* topics: list<array{
|
|
||||||
* topic_key: string,
|
|
||||||
* surface_families: list<string>,
|
|
||||||
* headline: string,
|
|
||||||
* short_explanation: string,
|
|
||||||
* troubleshooting_steps: list<string>,
|
|
||||||
* safe_next_action: string,
|
|
||||||
* glossary_terms: list<string>,
|
|
||||||
* docs_links: list<array{
|
|
||||||
* label: string,
|
|
||||||
* kind: string,
|
|
||||||
* url: ?string,
|
|
||||||
* resolver: ?string
|
|
||||||
* }>
|
|
||||||
* }>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function knowledgeSource(): array
|
|
||||||
{
|
|
||||||
return $this->catalog->knowledgeSource();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed>|null $verificationReport
|
|
||||||
*/
|
|
||||||
public function primaryReasonCodeFromVerificationReport(?array $verificationReport): ?string
|
|
||||||
{
|
|
||||||
foreach ($this->relevantChecks($verificationReport) as $check) {
|
|
||||||
$reasonCode = $check['reason_code'] ?? null;
|
|
||||||
|
|
||||||
if (is_string($reasonCode) && trim($reasonCode) !== '') {
|
|
||||||
return trim($reasonCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function topicKeyForOnboardingVerification(
|
|
||||||
?string $reasonCode,
|
|
||||||
bool $isVerificationStale,
|
|
||||||
?string $verificationOverall,
|
|
||||||
?string $runOutcome,
|
|
||||||
): ?string {
|
|
||||||
if ($isVerificationStale) {
|
|
||||||
return ContextualHelpCatalog::VERIFICATION_STALE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reasonTopicKey = $this->onboardingTopicKeyForReason($reasonCode);
|
|
||||||
|
|
||||||
if ($reasonTopicKey !== null) {
|
|
||||||
return $reasonTopicKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->isVerificationFailure($verificationOverall, $runOutcome)
|
|
||||||
? ContextualHelpCatalog::VERIFICATION_FAILED
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function topicKeyForSupportDiagnostics(
|
|
||||||
?string $reasonCode,
|
|
||||||
bool $hasIncompleteEvidence,
|
|
||||||
?string $runOutcome,
|
|
||||||
): ?string {
|
|
||||||
$reasonTopicKey = $this->supportDiagnosticsTopicKeyForReason($reasonCode);
|
|
||||||
|
|
||||||
if ($reasonTopicKey !== null) {
|
|
||||||
return $reasonTopicKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($reasonCode) && trim($reasonCode) !== '') {
|
|
||||||
return $reasonCode === ProviderReasonCodes::UnknownError
|
|
||||||
? ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->isVerificationFailure(null, $runOutcome)) {
|
|
||||||
return ContextualHelpCatalog::VERIFICATION_FAILED;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $hasIncompleteEvidence
|
|
||||||
? ContextualHelpCatalog::DIAGNOSTIC_EVIDENCE_INCOMPLETE
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
private function providerReasonEnvelope(array $context): ?ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
$tenant = $context['tenant'] ?? null;
|
|
||||||
$reasonCode = $context['reason_code'] ?? null;
|
|
||||||
$connection = $context['connection'] ?? null;
|
|
||||||
$surface = $this->stringOrNull($context['surface'] ?? null) ?? 'detail';
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! is_string($reasonCode) || trim($reasonCode) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->reasonPresenter->forProviderReason(
|
|
||||||
tenant: $tenant,
|
|
||||||
reasonCode: trim($reasonCode),
|
|
||||||
connection: $connection instanceof ProviderConnection ? $connection : null,
|
|
||||||
surface: $surface,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @return array{
|
|
||||||
* primaryReason: string,
|
|
||||||
* nextActionText: string,
|
|
||||||
* diagnosticsAvailable: bool,
|
|
||||||
* diagnosticsSummary: ?string
|
|
||||||
* }|null
|
|
||||||
*/
|
|
||||||
private function operatorSummary(array $context): ?array
|
|
||||||
{
|
|
||||||
$truth = $context['artifact_truth'] ?? null;
|
|
||||||
|
|
||||||
if (! $truth instanceof ArtifactTruthEnvelope) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->operatorExplanationBuilder->compressionSummaryInputs($truth);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed>|null $verificationReport
|
|
||||||
* @return list<array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function relevantChecks(?array $verificationReport): array
|
|
||||||
{
|
|
||||||
$checks = is_array($verificationReport['checks'] ?? null)
|
|
||||||
? $verificationReport['checks']
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return array_values(array_filter($checks, static function (mixed $check): bool {
|
|
||||||
if (! is_array($check)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = $check['status'] ?? null;
|
|
||||||
|
|
||||||
return is_string($status)
|
|
||||||
&& ! in_array($status, ['pass', 'skip', 'running'], true);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function onboardingTopicKeyForReason(?string $reasonCode): ?string
|
|
||||||
{
|
|
||||||
return match ($reasonCode) {
|
|
||||||
ProviderReasonCodes::ProviderConsentMissing,
|
|
||||||
ProviderReasonCodes::ProviderConsentFailed,
|
|
||||||
ProviderReasonCodes::ProviderConsentRevoked => ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED,
|
|
||||||
ProviderReasonCodes::ProviderPermissionMissing,
|
|
||||||
ProviderReasonCodes::ProviderPermissionDenied,
|
|
||||||
ProviderReasonCodes::IntuneRbacPermissionMissing => ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING,
|
|
||||||
ProviderReasonCodes::ProviderConnectionMissing,
|
|
||||||
ProviderReasonCodes::ProviderConnectionInvalid,
|
|
||||||
ProviderReasonCodes::ProviderCredentialMissing,
|
|
||||||
ProviderReasonCodes::ProviderCredentialInvalid,
|
|
||||||
ProviderReasonCodes::ProviderConnectionTypeInvalid,
|
|
||||||
ProviderReasonCodes::DedicatedCredentialMissing,
|
|
||||||
ProviderReasonCodes::DedicatedCredentialInvalid,
|
|
||||||
ProviderReasonCodes::ProviderAuthFailed,
|
|
||||||
ProviderReasonCodes::ProviderConnectionReviewRequired,
|
|
||||||
ProviderReasonCodes::ProviderBindingUnsupported,
|
|
||||||
ProviderReasonCodes::TenantTargetMismatch,
|
|
||||||
ProviderReasonCodes::PlatformIdentityMissing,
|
|
||||||
ProviderReasonCodes::PlatformIdentityIncomplete,
|
|
||||||
ProviderReasonCodes::IntuneRbacNotConfigured,
|
|
||||||
ProviderReasonCodes::IntuneRbacUnhealthy,
|
|
||||||
ProviderReasonCodes::IntuneRbacStale => ContextualHelpCatalog::CONNECTION_UNHEALTHY,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function supportDiagnosticsTopicKeyForReason(?string $reasonCode): ?string
|
|
||||||
{
|
|
||||||
if ($reasonCode === ProviderReasonCodes::ProviderPermissionRefreshFailed
|
|
||||||
|| $reasonCode === ProviderReasonCodes::NetworkUnreachable
|
|
||||||
|| $reasonCode === ProviderReasonCodes::RateLimited) {
|
|
||||||
return ContextualHelpCatalog::RETRYABLE_PROVIDER_FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->onboardingTopicKeyForReason($reasonCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $defaultSteps
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private function troubleshootingSteps(array $defaultSteps, array $context): array
|
|
||||||
{
|
|
||||||
$contextSteps = $context['troubleshooting_steps'] ?? [];
|
|
||||||
|
|
||||||
if (! is_array($contextSteps)) {
|
|
||||||
return $defaultSteps;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalizedContextSteps = array_values(array_filter(array_map(
|
|
||||||
fn (mixed $step): ?string => $this->stringOrNull($step),
|
|
||||||
$contextSteps,
|
|
||||||
)));
|
|
||||||
|
|
||||||
if ($normalizedContextSteps === []) {
|
|
||||||
return $defaultSteps;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique([...$defaultSteps, ...$normalizedContextSteps]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<array{label: string, kind: string, url?: string, resolver?: string}> $links
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @return list<array{label: string, kind: string, url: ?string, resolver: ?string}>
|
|
||||||
*/
|
|
||||||
private function resolveLinks(array $links, array $context): array
|
|
||||||
{
|
|
||||||
return array_values(array_filter(array_map(
|
|
||||||
fn (array $link): ?array => $this->resolveLink($link, $context['tenant'] ?? null),
|
|
||||||
$links,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{label: string, kind: string, url?: string, resolver?: string} $link
|
|
||||||
* @return array{label: string, kind: string, url: ?string, resolver: ?string}|null
|
|
||||||
*/
|
|
||||||
private function resolveLink(array $link, mixed $tenant): ?array
|
|
||||||
{
|
|
||||||
$label = $this->stringOrNull($link['label'] ?? null);
|
|
||||||
|
|
||||||
if ($label === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolved = [
|
|
||||||
'label' => $label,
|
|
||||||
'kind' => $this->stringOrNull($link['kind'] ?? null) ?? 'url',
|
|
||||||
'url' => $this->stringOrNull($link['url'] ?? null),
|
|
||||||
'resolver' => $this->stringOrNull($link['resolver'] ?? null),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || $resolved['resolver'] === null) {
|
|
||||||
return $resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolved['url'] = match ($resolved['resolver']) {
|
|
||||||
ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
|
|
||||||
ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS => RequiredPermissionsLinks::requiredPermissions($tenant),
|
|
||||||
default => $resolved['url'],
|
|
||||||
};
|
|
||||||
|
|
||||||
return $resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $terms
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private function canonicalTerms(array $terms): array
|
|
||||||
{
|
|
||||||
return array_values(array_unique(array_filter(array_map(function (string $term): ?string {
|
|
||||||
$canonical = $this->glossary->canonicalName($term);
|
|
||||||
|
|
||||||
return $canonical !== null ? trim($canonical) : $this->stringOrNull($term);
|
|
||||||
}, $terms))));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isVerificationFailure(?string $verificationOverall, ?string $runOutcome): bool
|
|
||||||
{
|
|
||||||
return in_array($verificationOverall, ['blocked', 'needs_attention'], true)
|
|
||||||
|| in_array($runOutcome, ['failed', 'blocked', 'partially_succeeded'], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function stringOrNull(mixed $value): ?string
|
|
||||||
{
|
|
||||||
if (! is_string($value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$trimmed = trim($value);
|
|
||||||
|
|
||||||
return $trimmed === '' ? null : $trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function firstNonEmpty(?string ...$values): string
|
|
||||||
{
|
|
||||||
foreach ($values as $value) {
|
|
||||||
if ($value !== null && trim($value) !== '') {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -22,7 +22,6 @@
|
|||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
|
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
|
||||||
use App\Support\ProductKnowledge\ContextualHelpResolver;
|
|
||||||
use App\Support\Providers\ProviderReasonTranslator;
|
use App\Support\Providers\ProviderReasonTranslator;
|
||||||
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||||
use App\Support\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
@ -48,7 +47,6 @@ public function __construct(
|
|||||||
private readonly GovernanceRunDiagnosticSummaryBuilder $runSummaryBuilder,
|
private readonly GovernanceRunDiagnosticSummaryBuilder $runSummaryBuilder,
|
||||||
private readonly ProviderReasonTranslator $providerReasonTranslator,
|
private readonly ProviderReasonTranslator $providerReasonTranslator,
|
||||||
private readonly RelatedNavigationResolver $relatedNavigationResolver,
|
private readonly RelatedNavigationResolver $relatedNavigationResolver,
|
||||||
private readonly ContextualHelpResolver $contextualHelpResolver,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -71,7 +69,6 @@ public function forTenant(Tenant $tenant, ?User $actor = null): array
|
|||||||
contextType: 'tenant',
|
contextType: 'tenant',
|
||||||
workspace: $workspace,
|
workspace: $workspace,
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
providerConnection: $providerConnection,
|
|
||||||
operationRun: $operationRun,
|
operationRun: $operationRun,
|
||||||
headline: 'Support diagnostics for '.$tenant->name,
|
headline: 'Support diagnostics for '.$tenant->name,
|
||||||
dominantIssue: $this->tenantDominantIssue($providerConnection, $operationRun, $findings),
|
dominantIssue: $this->tenantDominantIssue($providerConnection, $operationRun, $findings),
|
||||||
@ -112,7 +109,6 @@ public function forOperationRun(OperationRun $run, ?User $actor = null): array
|
|||||||
contextType: 'operation_run',
|
contextType: 'operation_run',
|
||||||
workspace: $workspace,
|
workspace: $workspace,
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
providerConnection: $providerConnection,
|
|
||||||
operationRun: $run,
|
operationRun: $run,
|
||||||
headline: $runSummaryArray['headline'] ?? OperationRunLinks::identifier($run).' support diagnostics',
|
headline: $runSummaryArray['headline'] ?? OperationRunLinks::identifier($run).' support diagnostics',
|
||||||
dominantIssue: (string) data_get(
|
dominantIssue: (string) data_get(
|
||||||
@ -141,7 +137,6 @@ private function bundle(
|
|||||||
string $contextType,
|
string $contextType,
|
||||||
?Workspace $workspace,
|
?Workspace $workspace,
|
||||||
?Tenant $tenant,
|
?Tenant $tenant,
|
||||||
?ProviderConnection $providerConnection,
|
|
||||||
?OperationRun $operationRun,
|
?OperationRun $operationRun,
|
||||||
string $headline,
|
string $headline,
|
||||||
string $dominantIssue,
|
string $dominantIssue,
|
||||||
@ -149,7 +144,6 @@ private function bundle(
|
|||||||
): array {
|
): array {
|
||||||
$sections = $this->sortSections($sections);
|
$sections = $this->sortSections($sections);
|
||||||
$redactionMarkers = RedactionIntegrity::supportDiagnosticsMarkers();
|
$redactionMarkers = RedactionIntegrity::supportDiagnosticsMarkers();
|
||||||
$contextualHelp = $this->contextualHelp($tenant, $providerConnection, $operationRun, $sections);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'context_type' => $contextType,
|
'context_type' => $contextType,
|
||||||
@ -179,7 +173,6 @@ private function bundle(
|
|||||||
'redaction_note' => RedactionIntegrity::supportDiagnosticsNote(),
|
'redaction_note' => RedactionIntegrity::supportDiagnosticsNote(),
|
||||||
'generated_from' => 'derived_existing_truth',
|
'generated_from' => 'derived_existing_truth',
|
||||||
],
|
],
|
||||||
'contextual_help' => $contextualHelp,
|
|
||||||
'sections' => $sections,
|
'sections' => $sections,
|
||||||
'redaction' => [
|
'redaction' => [
|
||||||
'mode' => 'default_redacted',
|
'mode' => 'default_redacted',
|
||||||
@ -192,60 +185,6 @@ private function bundle(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<array<string, mixed>> $sections
|
|
||||||
* @return array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
private function contextualHelp(
|
|
||||||
?Tenant $tenant,
|
|
||||||
?ProviderConnection $providerConnection,
|
|
||||||
?OperationRun $operationRun,
|
|
||||||
array $sections,
|
|
||||||
): ?array {
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reasonCode = $this->supportDiagnosticReasonCode($providerConnection, $operationRun);
|
|
||||||
$topicKey = $this->contextualHelpResolver->topicKeyForSupportDiagnostics(
|
|
||||||
reasonCode: $reasonCode,
|
|
||||||
hasIncompleteEvidence: $this->completenessNote($sections) !== null,
|
|
||||||
runOutcome: $operationRun instanceof OperationRun && is_string($operationRun->outcome)
|
|
||||||
? (string) $operationRun->outcome
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($topicKey === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->contextualHelpResolver->tryResolve($topicKey, [
|
|
||||||
'tenant' => $tenant,
|
|
||||||
'connection' => $providerConnection,
|
|
||||||
'reason_code' => $reasonCode,
|
|
||||||
'surface' => 'support_diagnostics',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function supportDiagnosticReasonCode(?ProviderConnection $providerConnection, ?OperationRun $operationRun): ?string
|
|
||||||
{
|
|
||||||
$providerReasonCode = is_string($providerConnection?->last_error_reason_code)
|
|
||||||
? trim((string) $providerConnection->last_error_reason_code)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
if ($providerReasonCode !== '') {
|
|
||||||
return $providerReasonCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
$failureReasonCode = data_get($operationRun?->failure_summary, '0.reason_code');
|
|
||||||
|
|
||||||
if (is_string($failureReasonCode) && trim($failureReasonCode) !== '') {
|
|
||||||
return trim($failureReasonCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function tenantProviderConnection(Tenant $tenant): ?ProviderConnection
|
private function tenantProviderConnection(Tenant $tenant): ?ProviderConnection
|
||||||
{
|
{
|
||||||
return ProviderConnection::query()
|
return ProviderConnection::query()
|
||||||
|
|||||||
@ -1,80 +0,0 @@
|
|||||||
@php
|
|
||||||
$help = is_array($help ?? null) ? $help : [];
|
|
||||||
$links = is_array($help['docs_links'] ?? null) ? $help['docs_links'] : [];
|
|
||||||
$steps = is_array($help['troubleshooting_steps'] ?? null) ? $help['troubleshooting_steps'] : [];
|
|
||||||
$headline = is_string($help['headline'] ?? null) && trim((string) ($help['headline'] ?? '')) !== ''
|
|
||||||
? (string) ($help['headline'])
|
|
||||||
: 'Contextual help';
|
|
||||||
$reasonLabel = is_string($help['reason_label'] ?? null) && trim((string) ($help['reason_label'] ?? '')) !== ''
|
|
||||||
? (string) $help['reason_label']
|
|
||||||
: null;
|
|
||||||
$showReasonLabel = $reasonLabel !== null && trim(mb_strtolower($reasonLabel)) !== trim(mb_strtolower($headline));
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@if ($help !== [])
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/80" data-testid="contextual-help-block">
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<x-filament::badge color="info" size="sm">
|
|
||||||
Contextual help
|
|
||||||
</x-filament::badge>
|
|
||||||
|
|
||||||
@if ($showReasonLabel)
|
|
||||||
<x-filament::badge color="gray" size="sm">
|
|
||||||
{{ $reasonLabel }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ $headline }}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
|
||||||
{{ (string) ($help['short_explanation'] ?? '') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (is_string($help['safe_next_action'] ?? null) && trim((string) ($help['safe_next_action'] ?? '')) !== '')
|
|
||||||
<x-filament::callout
|
|
||||||
color="info"
|
|
||||||
heading="Safe next action"
|
|
||||||
:description="(string) $help['safe_next_action']"
|
|
||||||
/>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($steps !== [])
|
|
||||||
<ul class="list-disc space-y-1 pl-5 text-sm text-gray-700 dark:text-gray-200">
|
|
||||||
@foreach ($steps as $step)
|
|
||||||
@if (is_string($step) && trim($step) !== '')
|
|
||||||
<li>{{ $step }}</li>
|
|
||||||
@endif
|
|
||||||
@endforeach
|
|
||||||
</ul>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($links !== [])
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
@foreach ($links as $link)
|
|
||||||
@php
|
|
||||||
$linkUrl = is_string($link['url'] ?? null) && trim((string) ($link['url'] ?? '')) !== ''
|
|
||||||
? (string) $link['url']
|
|
||||||
: null;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@if ($linkUrl)
|
|
||||||
<x-filament::button
|
|
||||||
tag="a"
|
|
||||||
:href="$linkUrl"
|
|
||||||
size="sm"
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{{ (string) ($link['label'] ?? 'Open') }}
|
|
||||||
</x-filament::button>
|
|
||||||
@endif
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@ -6,7 +6,6 @@
|
|||||||
$redactionNotes = is_array($redactionNotes ?? null)
|
$redactionNotes = is_array($redactionNotes ?? null)
|
||||||
? array_values(array_filter($redactionNotes, 'is_string'))
|
? array_values(array_filter($redactionNotes, 'is_string'))
|
||||||
: [];
|
: [];
|
||||||
$contextualHelp = is_array($contextualHelp ?? null) ? $contextualHelp : null;
|
|
||||||
$assistVisibility = is_array($assistVisibility ?? null) ? $assistVisibility : [];
|
$assistVisibility = is_array($assistVisibility ?? null) ? $assistVisibility : [];
|
||||||
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
|
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
|
||||||
? trim((string) $assistActionName)
|
? trim((string) $assistActionName)
|
||||||
@ -15,6 +14,12 @@
|
|||||||
? trim((string) $technicalDetailsActionName)
|
? trim((string) $technicalDetailsActionName)
|
||||||
: 'wizardVerificationTechnicalDetails';
|
: 'wizardVerificationTechnicalDetails';
|
||||||
$showAssist = (bool) ($assistVisibility['is_visible'] ?? false);
|
$showAssist = (bool) ($assistVisibility['is_visible'] ?? false);
|
||||||
|
$assistReason = is_string($assistVisibility['reason'] ?? null) ? (string) $assistVisibility['reason'] : 'hidden_irrelevant';
|
||||||
|
$assistDescription = match ($assistReason) {
|
||||||
|
'permission_blocked' => 'Stored permission diagnostics show blockers. Review them without leaving onboarding.',
|
||||||
|
'permission_attention' => 'Stored permission diagnostics need attention before you rerun verification.',
|
||||||
|
default => 'Review required permissions without leaving onboarding.',
|
||||||
|
};
|
||||||
$completedAt = is_string($run['completed_at'] ?? null) && trim((string) $run['completed_at']) !== '' ? (string) $run['completed_at'] : null;
|
$completedAt = is_string($run['completed_at'] ?? null) && trim((string) $run['completed_at']) !== '' ? (string) $run['completed_at'] : null;
|
||||||
$completedAtLabel = null;
|
$completedAtLabel = null;
|
||||||
|
|
||||||
@ -47,7 +52,7 @@
|
|||||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<x-filament::section
|
<x-filament::section
|
||||||
heading="Stored verification details"
|
heading="Verification report"
|
||||||
:description="$completedAtLabel ? ('Completed: ' . $completedAtLabel) : 'Stored details for the latest verification operation.'"
|
:description="$completedAtLabel ? ('Completed: ' . $completedAtLabel) : 'Stored details for the latest verification operation.'"
|
||||||
>
|
>
|
||||||
@if ($runState === 'no_run')
|
@if ($runState === 'no_run')
|
||||||
@ -108,8 +113,28 @@
|
|||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($contextualHelp !== null)
|
@if ($showAssist)
|
||||||
@include('filament.components.product-knowledge.contextual-help', ['help' => $contextualHelp])
|
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 shadow-sm dark:border-warning-700 dark:bg-warning-950/40">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-semibold text-warning-950 dark:text-warning-50">
|
||||||
|
Required permissions assist
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-warning-900 dark:text-warning-100">
|
||||||
|
{{ $assistDescription }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
size="sm"
|
||||||
|
color="warning"
|
||||||
|
data-testid="verification-assist-trigger"
|
||||||
|
wire:click="mountAction('{{ $assistActionName }}')"
|
||||||
|
>
|
||||||
|
View required permissions
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@include('filament.components.verification-report-viewer', [
|
@include('filament.components.verification-report-viewer', [
|
||||||
|
|||||||
@ -66,10 +66,6 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
@if (is_array($bundle['contextual_help'] ?? null))
|
|
||||||
@include('filament.components.product-knowledge.contextual-help', ['help' => $bundle['contextual_help']])
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($notes !== [])
|
@if ($notes !== [])
|
||||||
<x-filament::section
|
<x-filament::section
|
||||||
heading="Support notes"
|
heading="Support notes"
|
||||||
@ -164,4 +160,3 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
@php
|
|
||||||
/** @var array{
|
|
||||||
* overall: array{label: string, color: string, icon: string|null},
|
|
||||||
* reason: string,
|
|
||||||
* impact: string,
|
|
||||||
* recommended_action: string,
|
|
||||||
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
|
|
||||||
* window_label: string
|
|
||||||
* } $decision */
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<x-filament::section>
|
|
||||||
<x-slot name="heading">
|
|
||||||
Customer health decision
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<x-slot name="description">
|
|
||||||
Decision-first summary. Operational stability, review-pack readiness, and engagement freshness honor {{ $decision['window_label'] }}.
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<div class="space-y-5">
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-gray-950 dark:text-white">Overall health</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-filament::badge :color="$decision['overall']['color']" :icon="$decision['overall']['icon']">
|
|
||||||
{{ $decision['overall']['label'] }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<p class="text-sm font-medium text-gray-950 dark:text-white">Reason</p>
|
|
||||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $decision['reason'] }}</p>
|
|
||||||
|
|
||||||
@if ($decision['dominant_dimensions'] !== [])
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
@foreach ($decision['dominant_dimensions'] as $dimension)
|
|
||||||
<x-filament::badge :color="$dimension['color']" :icon="$dimension['icon']">
|
|
||||||
{{ $dimension['label'] }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<p class="text-sm font-medium text-gray-950 dark:text-white">Impact</p>
|
|
||||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $decision['impact'] }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<p class="text-sm font-medium text-gray-950 dark:text-white">Recommended next action</p>
|
|
||||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $decision['recommended_action'] }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
@php
|
@php
|
||||||
/** @var \App\Models\Tenant $tenant */
|
/** @var \App\Models\Tenant $tenant */
|
||||||
$tenant = $this->tenant;
|
$tenant = $this->tenant;
|
||||||
$customerHealthDecision = $this->customerHealthDecision();
|
|
||||||
$providerConnections = $this->providerConnections();
|
$providerConnections = $this->providerConnections();
|
||||||
$permissions = $this->tenantPermissions();
|
$permissions = $this->tenantPermissions();
|
||||||
$runs = $this->recentRuns();
|
$runs = $this->recentRuns();
|
||||||
@ -33,19 +32,11 @@
|
|||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<x-filament::link :href="$this->adminTenantUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
<x-filament::link :href="$this->adminTenantUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
||||||
Open in tenant admin
|
Open in /admin
|
||||||
</x-filament::link>
|
</x-filament::link>
|
||||||
|
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Requires tenant admin membership.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
@if ($customerHealthDecision)
|
|
||||||
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<x-slot name="heading">
|
<x-slot name="heading">
|
||||||
Connectivity signals
|
Connectivity signals
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
@php
|
@php
|
||||||
/** @var \App\Models\Workspace $workspace */
|
/** @var \App\Models\Workspace $workspace */
|
||||||
$workspace = $this->workspace;
|
$workspace = $this->workspace;
|
||||||
$customerHealthDecision = $this->customerHealthDecision();
|
|
||||||
$tenants = $this->workspaceTenants();
|
$tenants = $this->workspaceTenants();
|
||||||
$runs = $this->recentRuns();
|
$runs = $this->recentRuns();
|
||||||
@endphp
|
@endphp
|
||||||
@ -31,10 +30,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
@if ($customerHealthDecision)
|
|
||||||
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<x-slot name="heading">
|
<x-slot name="heading">
|
||||||
Tenants summary
|
Tenants summary
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
<x-filament-widgets::widget>
|
|
||||||
<x-filament::section>
|
|
||||||
<x-slot name="heading">
|
|
||||||
Attention-needed workspaces
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<x-slot name="description">
|
|
||||||
Worst derived workspace health first. Operational stability, review-pack readiness, and engagement freshness honor {{ $windowLabel }}.
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
@if ($rows->isEmpty())
|
|
||||||
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-500 dark:border-white/15 dark:text-gray-400">
|
|
||||||
No workspaces need attention in the selected view.
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="space-y-3">
|
|
||||||
@foreach ($rows as $row)
|
|
||||||
<article class="rounded-xl border border-gray-200 bg-white/70 p-4 dark:border-white/10 dark:bg-white/5">
|
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-950 dark:text-white">{{ $row['workspace_label'] }}</h3>
|
|
||||||
|
|
||||||
<x-filament::badge :color="$row['overall']['color']" :icon="$row['overall']['icon']">
|
|
||||||
{{ $row['overall']['label'] }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Top drivers: {{ $row['dominant_copy'] }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
@foreach ($row['dominant_dimensions'] as $dimension)
|
|
||||||
<x-filament::badge :color="$dimension['color']" :icon="$dimension['icon']">
|
|
||||||
{{ $dimension['label'] }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<x-filament::link :href="$row['next_link']['url']" icon="heroicon-m-arrow-top-right-on-square">
|
|
||||||
{{ $row['next_link']['label'] }}
|
|
||||||
</x-filament::link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</x-filament::section>
|
|
||||||
</x-filament-widgets::widget>
|
|
||||||
@ -1063,8 +1063,7 @@ function createManagedReadinessBlockerDraft(string $state): array
|
|||||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Onboarding readiness')
|
->assertSee('Onboarding readiness')
|
||||||
->assertSee('Step')
|
->assertSee('Current checkpoint')
|
||||||
->assertDontSee('Current checkpoint')
|
|
||||||
->assertSee('Verify access')
|
->assertSee('Verify access')
|
||||||
->assertSee('Verification has not run yet')
|
->assertSee('Verification has not run yet')
|
||||||
->assertSee('Provider connection')
|
->assertSee('Provider connection')
|
||||||
@ -1166,46 +1165,28 @@ function createManagedReadinessBlockerDraft(string $state): array
|
|||||||
->assertSee($summary)
|
->assertSee($summary)
|
||||||
->assertSee($nextAction);
|
->assertSee($nextAction);
|
||||||
})->with([
|
})->with([
|
||||||
'missing consent' => ['missing_consent', 'Provider consent required', 'Grant admin consent'],
|
'missing consent' => ['missing_consent', 'Provider consent required', 'Grant consent'],
|
||||||
'revoked consent' => ['revoked_consent', 'Provider consent revoked', 'Grant admin consent'],
|
'revoked consent' => ['revoked_consent', 'Provider consent revoked', 'Grant consent'],
|
||||||
'disabled connection' => ['disabled_connection', 'Provider connection disabled', 'Review provider connection'],
|
'disabled connection' => ['disabled_connection', 'Provider connection disabled', 'Review provider connection'],
|
||||||
'blocked verification' => ['blocked_verification', 'Permission or consent blocker needs attention', 'Review permissions'],
|
'blocked verification' => ['blocked_verification', 'Permission or consent blocker needs attention', 'Review permissions'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
it('keeps permission gap detail out of the top-level page once a verification report is present', function (): void {
|
it('keeps permission gap diagnostics provider-owned while top-level readiness stays neutral', function (): void {
|
||||||
[$user, $draft, , , $missingKey] = createManagedReadinessBlockerDraft('permission_gap');
|
[$user, $draft, , , $missingKey] = createManagedReadinessBlockerDraft('permission_gap');
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Permission or consent blocker needs attention')
|
->assertSee('Permission or consent blocker needs attention')
|
||||||
->assertDontSee('Permission diagnostics')
|
|
||||||
->assertSee('Supporting evidence')
|
|
||||||
->assertSee('View required permissions')
|
|
||||||
->assertSee('Review permissions');
|
|
||||||
|
|
||||||
if (is_string($missingKey) && $missingKey !== '') {
|
|
||||||
$response->assertDontSee($missingKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response->assertDontSee('Microsoft Graph readiness');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows permission diagnostics as a fallback when no verification report is present', function (): void {
|
|
||||||
[$user, $draft] = createManagedReadinessBlockerDraft('missing_consent');
|
|
||||||
|
|
||||||
$tenant = $draft->tenant()->firstOrFail();
|
|
||||||
$missingKey = seedManagedReadinessPermissions($tenant);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
|
||||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSee('Permission diagnostics')
|
->assertSee('Permission diagnostics')
|
||||||
->assertDontSee('Supporting evidence');
|
->assertSee('Missing application permissions')
|
||||||
|
->assertSee('Review permissions');
|
||||||
|
|
||||||
if (is_string($missingKey) && $missingKey !== '') {
|
if (is_string($missingKey) && $missingKey !== '') {
|
||||||
$response->assertSee($missingKey);
|
$response->assertSee($missingKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$response->assertDontSee('Microsoft Graph readiness');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('downgrades route-bound readiness when permission evidence is stale and keeps the operation link canonical', function (): void {
|
it('downgrades route-bound readiness when permission evidence is stale and keeps the operation link canonical', function (): void {
|
||||||
|
|||||||
@ -1,265 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantOnboardingSession;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\ProductKnowledge\ContextualHelpCatalog;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
|
||||||
use App\Support\Verification\VerificationReportWriter;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: User, 1: Tenant, 2: TenantOnboardingSession}
|
|
||||||
*/
|
|
||||||
function createProductKnowledgeOnboardingDraft(string $state, string $workspaceRole = 'owner', string $tenantRole = 'owner'): array
|
|
||||||
{
|
|
||||||
$tenant = Tenant::factory()->onboarding()->create();
|
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(
|
|
||||||
tenant: $tenant,
|
|
||||||
role: $tenantRole,
|
|
||||||
workspaceRole: $workspaceRole,
|
|
||||||
ensureDefaultMicrosoftProviderConnection: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
$workspace = $tenant->workspace()->firstOrFail();
|
|
||||||
|
|
||||||
$verificationConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'provider' => 'microsoft',
|
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
||||||
'display_name' => 'Verification connection',
|
|
||||||
'is_default' => true,
|
|
||||||
'consent_status' => 'granted',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$selectedConnection = $verificationConnection;
|
|
||||||
$checks = [];
|
|
||||||
$outcome = OperationRunOutcome::Blocked->value;
|
|
||||||
|
|
||||||
if ($state === 'admin_consent') {
|
|
||||||
$checks[] = [
|
|
||||||
'key' => 'permissions.admin_consent',
|
|
||||||
'title' => 'Admin consent',
|
|
||||||
'status' => 'fail',
|
|
||||||
'severity' => 'critical',
|
|
||||||
'blocking' => true,
|
|
||||||
'reason_code' => ProviderReasonCodes::ProviderConsentMissing,
|
|
||||||
'message' => 'Admin consent is required before verification can proceed.',
|
|
||||||
'evidence' => [],
|
|
||||||
'next_steps' => [],
|
|
||||||
];
|
|
||||||
} elseif ($state === 'required_permissions') {
|
|
||||||
$checks[] = [
|
|
||||||
'key' => 'permissions.required',
|
|
||||||
'title' => 'Required application permissions',
|
|
||||||
'status' => 'fail',
|
|
||||||
'severity' => 'critical',
|
|
||||||
'blocking' => true,
|
|
||||||
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
|
|
||||||
'message' => 'Missing required application permissions.',
|
|
||||||
'evidence' => [],
|
|
||||||
'next_steps' => [],
|
|
||||||
];
|
|
||||||
} elseif ($state === 'connection_unhealthy') {
|
|
||||||
$checks[] = [
|
|
||||||
'key' => 'provider.connection.check',
|
|
||||||
'title' => 'Provider connection check',
|
|
||||||
'status' => 'fail',
|
|
||||||
'severity' => 'critical',
|
|
||||||
'blocking' => true,
|
|
||||||
'reason_code' => ProviderReasonCodes::ProviderAuthFailed,
|
|
||||||
'message' => 'Stored provider credentials are no longer valid.',
|
|
||||||
'evidence' => [],
|
|
||||||
'next_steps' => [],
|
|
||||||
];
|
|
||||||
} elseif ($state === 'verification_stale') {
|
|
||||||
$selectedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'provider' => 'dummy',
|
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
||||||
'display_name' => 'Currently selected connection',
|
|
||||||
'is_default' => false,
|
|
||||||
'consent_status' => 'granted',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$checks[] = [
|
|
||||||
'key' => 'provider.connection.check',
|
|
||||||
'title' => 'Provider connection check',
|
|
||||||
'status' => 'pass',
|
|
||||||
'severity' => 'info',
|
|
||||||
'blocking' => false,
|
|
||||||
'reason_code' => 'ok',
|
|
||||||
'message' => 'Connection is healthy.',
|
|
||||||
'evidence' => [],
|
|
||||||
'next_steps' => [],
|
|
||||||
];
|
|
||||||
|
|
||||||
$outcome = OperationRunOutcome::Succeeded->value;
|
|
||||||
} elseif ($state === 'verification_failed') {
|
|
||||||
$checks[] = [
|
|
||||||
'key' => 'provider.connection.check',
|
|
||||||
'title' => 'Provider connection check',
|
|
||||||
'status' => 'fail',
|
|
||||||
'severity' => 'critical',
|
|
||||||
'blocking' => true,
|
|
||||||
'reason_code' => '',
|
|
||||||
'message' => 'Verification failed after the prerequisite checks ran.',
|
|
||||||
'evidence' => [],
|
|
||||||
'next_steps' => [],
|
|
||||||
];
|
|
||||||
|
|
||||||
$outcome = OperationRunOutcome::Failed->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'type' => 'provider.connection.check',
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => $outcome,
|
|
||||||
'context' => [
|
|
||||||
'provider_connection_id' => (int) $verificationConnection->getKey(),
|
|
||||||
'target_scope' => [
|
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
||||||
'entra_tenant_name' => (string) $tenant->name,
|
|
||||||
],
|
|
||||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', $checks),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$draft = createOnboardingDraft([
|
|
||||||
'workspace' => $workspace,
|
|
||||||
'tenant' => $tenant,
|
|
||||||
'started_by' => $user,
|
|
||||||
'updated_by' => $user,
|
|
||||||
'current_step' => 'verify',
|
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
||||||
'state' => [
|
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
||||||
'tenant_name' => (string) $tenant->name,
|
|
||||||
'provider_connection_id' => (int) $selectedConnection->getKey(),
|
|
||||||
'verification_operation_run_id' => (int) $run->getKey(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
||||||
|
|
||||||
return [$user, $tenant, $draft];
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders onboarding contextual help for each in-scope verification topic', function (
|
|
||||||
string $state,
|
|
||||||
string $headline,
|
|
||||||
string $safeNextAction,
|
|
||||||
?string $linkLabel,
|
|
||||||
): void {
|
|
||||||
[$user, , $draft] = createProductKnowledgeOnboardingDraft($state);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $user->last_workspace_id])
|
|
||||||
->followingRedirects()
|
|
||||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
|
|
||||||
|
|
||||||
$response->assertSuccessful()
|
|
||||||
->assertSee('Verification report')
|
|
||||||
->assertSee('Stored verification details')
|
|
||||||
->assertSee($headline)
|
|
||||||
->assertDontSee('Permission diagnostics')
|
|
||||||
->assertSee($safeNextAction);
|
|
||||||
|
|
||||||
$dom = new \DOMDocument();
|
|
||||||
@$dom->loadHTML($response->getContent());
|
|
||||||
|
|
||||||
$xpath = new \DOMXPath($dom);
|
|
||||||
|
|
||||||
$headlineNodes = $xpath->query(sprintf(
|
|
||||||
'//*[@data-testid="contextual-help-block"]//*[normalize-space(text())="%s"]',
|
|
||||||
$headline,
|
|
||||||
));
|
|
||||||
|
|
||||||
$storedVerificationDetailsHeadings = $xpath->query(
|
|
||||||
'//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6][normalize-space(text())="Stored verification details"]',
|
|
||||||
);
|
|
||||||
|
|
||||||
$verificationReportHeadings = $xpath->query(
|
|
||||||
'//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6][normalize-space(text())="Verification report"]',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($headlineNodes?->length)->toBe(1);
|
|
||||||
expect($storedVerificationDetailsHeadings?->length)->toBe(1);
|
|
||||||
expect($verificationReportHeadings?->length)->toBeLessThanOrEqual(1);
|
|
||||||
|
|
||||||
if ($state === 'admin_consent') {
|
|
||||||
$primaryNextActionNode = $xpath->query(
|
|
||||||
'//*[normalize-space(text())="Primary next action"]/following::*[(self::a or self::button) and normalize-space(text())!=""][1]',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(trim((string) $primaryNextActionNode?->item(0)?->textContent))->toContain('Grant admin consent');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($linkLabel !== null) {
|
|
||||||
$response->assertSee($linkLabel);
|
|
||||||
}
|
|
||||||
})->with([
|
|
||||||
'admin consent required' => [
|
|
||||||
'admin_consent',
|
|
||||||
'Admin consent required',
|
|
||||||
'Grant admin consent and re-run verification.',
|
|
||||||
'Grant admin consent',
|
|
||||||
],
|
|
||||||
'required permissions missing' => [
|
|
||||||
'required_permissions',
|
|
||||||
'Required permissions missing',
|
|
||||||
'Open required permissions and confirm the missing grants.',
|
|
||||||
'Open required permissions',
|
|
||||||
],
|
|
||||||
'connection unhealthy' => [
|
|
||||||
'connection_unhealthy',
|
|
||||||
'Provider connection needs review',
|
|
||||||
'Review the provider connection before retrying.',
|
|
||||||
null,
|
|
||||||
],
|
|
||||||
'verification stale' => [
|
|
||||||
'verification_stale',
|
|
||||||
'Verification result is stale',
|
|
||||||
'Refresh verification before continuing onboarding.',
|
|
||||||
null,
|
|
||||||
],
|
|
||||||
'verification failed' => [
|
|
||||||
'verification_failed',
|
|
||||||
'Verification failed',
|
|
||||||
'Review the blocking reason and retry verification.',
|
|
||||||
null,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
it('keeps onboarding contextual help deny-as-not-found for workspace members outside the tenant scope', function (): void {
|
|
||||||
[$authorizedUser, $tenant, $draft] = createProductKnowledgeOnboardingDraft('admin_consent');
|
|
||||||
|
|
||||||
$workspace = $tenant->workspace()->firstOrFail();
|
|
||||||
$outOfScopeUser = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::query()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $outOfScopeUser->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($outOfScopeUser)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
@ -1,133 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
function productKnowledgeSupportDiagnosticsTenantAuthorizationComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
|
|
||||||
{
|
|
||||||
test()->actingAs($user);
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
setTenantPanelContext($tenant);
|
|
||||||
|
|
||||||
return Livewire::actingAs($user)->test(TenantDashboard::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
function productKnowledgeSupportDiagnosticsOperationAuthorizationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
|
|
||||||
{
|
|
||||||
test()->actingAs($user);
|
|
||||||
Filament::setTenant(null, true);
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
|
|
||||||
|
|
||||||
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('keeps tenant support diagnostics contextual help deny-as-not-found for workspace members without tenant entitlement', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'operator',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
|
||||||
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns forbidden for entitled run viewers without support diagnostics capability when requesting the contextual-help bundle', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'type' => OperationRunType::BaselineCompare->value,
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'completed_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
productKnowledgeSupportDiagnosticsOperationAuthorizationComponent($user, $run)
|
|
||||||
->assertActionVisible('openSupportDiagnostics')
|
|
||||||
->assertActionDisabled('openSupportDiagnostics')
|
|
||||||
->call('operationRunSupportDiagnosticBundle')
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits support diagnostics contextual help when the dominant issue does not map to a catalog topic', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create(['name' => 'Fallback Support Tenant']);
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()
|
|
||||||
->withCredential()
|
|
||||||
->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'display_name' => 'Fallback connection',
|
|
||||||
'last_error_reason_code' => 'ext.support.manual_lookup_needed',
|
|
||||||
'last_health_check_at' => now()->subMinutes(5),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$run = OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'type' => OperationRunType::BaselineCompare->value,
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'context' => [
|
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
|
||||||
],
|
|
||||||
'completed_at' => now()->subMinutes(3),
|
|
||||||
]);
|
|
||||||
|
|
||||||
productKnowledgeSupportDiagnosticsOperationAuthorizationComponent($user, $run)
|
|
||||||
->mountAction('openSupportDiagnostics')
|
|
||||||
->assertMountedActionModalDontSee('Contextual help')
|
|
||||||
->assertMountedActionModalDontSee('ext.support.manual_lookup_needed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps operation-run support diagnostics deny-as-not-found for workspace members without tenant entitlement', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$tenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'type' => OperationRunType::BaselineCompare->value,
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'completed_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
function productKnowledgeTenantSupportDiagnosticsComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
|
|
||||||
{
|
|
||||||
test()->actingAs($user);
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
setTenantPanelContext($tenant);
|
|
||||||
|
|
||||||
return Livewire::actingAs($user)->test(TenantDashboard::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
function productKnowledgeOperationSupportDiagnosticsComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
|
|
||||||
{
|
|
||||||
test()->actingAs($user);
|
|
||||||
Filament::setTenant(null, true);
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
|
|
||||||
|
|
||||||
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: User, 1: Tenant, 2: OperationRun}
|
|
||||||
*/
|
|
||||||
function createProductKnowledgeSupportDiagnosticScenario(string $state): array
|
|
||||||
{
|
|
||||||
$tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']);
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
|
||||||
|
|
||||||
$reasonCode = match ($state) {
|
|
||||||
'admin_consent' => ProviderReasonCodes::ProviderConsentMissing,
|
|
||||||
'required_permissions' => ProviderReasonCodes::ProviderPermissionMissing,
|
|
||||||
'connection_unhealthy' => ProviderReasonCodes::ProviderAuthFailed,
|
|
||||||
'retryable_provider_failure' => ProviderReasonCodes::RateLimited,
|
|
||||||
'manual_handoff_required' => ProviderReasonCodes::UnknownError,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
$connection = $reasonCode !== null
|
|
||||||
? ProviderConnection::factory()->withCredential()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'display_name' => 'Contoso Microsoft connection',
|
|
||||||
'verification_status' => $reasonCode === ProviderReasonCodes::UnknownError
|
|
||||||
? ProviderVerificationStatus::Blocked->value
|
|
||||||
: ProviderVerificationStatus::Healthy->value,
|
|
||||||
'last_error_reason_code' => $reasonCode,
|
|
||||||
'last_health_check_at' => now()->subMinutes(15),
|
|
||||||
])
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$runOutcome = match ($state) {
|
|
||||||
'verification_failed', 'manual_handoff_required' => OperationRunOutcome::Failed->value,
|
|
||||||
default => OperationRunOutcome::Succeeded->value,
|
|
||||||
};
|
|
||||||
|
|
||||||
$failureSummary = match ($state) {
|
|
||||||
'verification_failed' => [[
|
|
||||||
'message' => 'The operation failed and needs follow-up.',
|
|
||||||
]],
|
|
||||||
'manual_handoff_required' => [[
|
|
||||||
'message' => 'A human support handoff is required for the next step.',
|
|
||||||
'reason_code' => ProviderReasonCodes::UnknownError,
|
|
||||||
]],
|
|
||||||
default => [],
|
|
||||||
};
|
|
||||||
|
|
||||||
$context = [];
|
|
||||||
|
|
||||||
if ($connection instanceof ProviderConnection) {
|
|
||||||
$context['provider_connection_id'] = (int) $connection->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'type' => OperationRunType::BaselineCompare->value,
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => $runOutcome,
|
|
||||||
'summary_counts' => [
|
|
||||||
'total' => 0,
|
|
||||||
'processed' => 0,
|
|
||||||
],
|
|
||||||
'context' => $context,
|
|
||||||
'failure_summary' => $failureSummary,
|
|
||||||
'completed_at' => now()->subMinutes(10),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [$user, $tenant, $run];
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders shared product knowledge in tenant support diagnostics', function (
|
|
||||||
string $state,
|
|
||||||
string $headline,
|
|
||||||
string $safeNextAction,
|
|
||||||
?string $linkLabel,
|
|
||||||
): void {
|
|
||||||
[$user, $tenant] = createProductKnowledgeSupportDiagnosticScenario($state);
|
|
||||||
|
|
||||||
$component = productKnowledgeTenantSupportDiagnosticsComponent($user, $tenant);
|
|
||||||
|
|
||||||
$component->mountAction('openSupportDiagnostics')
|
|
||||||
->assertMountedActionModalSee('Contextual help')
|
|
||||||
->assertMountedActionModalSee($headline)
|
|
||||||
->assertMountedActionModalSee($safeNextAction);
|
|
||||||
|
|
||||||
if ($linkLabel !== null) {
|
|
||||||
$component->assertMountedActionModalSee($linkLabel);
|
|
||||||
}
|
|
||||||
})->with([
|
|
||||||
'tenant admin consent required' => ['admin_consent', 'Admin consent required', 'Grant admin consent and re-run verification.', 'Grant admin consent'],
|
|
||||||
'tenant required permissions missing' => ['required_permissions', 'Required permissions missing', 'Open required permissions and confirm the missing grants.', 'Open required permissions'],
|
|
||||||
'tenant connection unhealthy' => ['connection_unhealthy', 'Provider connection needs review', 'Review the provider connection before retrying.', null],
|
|
||||||
'tenant verification failed' => ['verification_failed', 'Verification failed', 'Review the blocking reason and retry verification.', null],
|
|
||||||
'tenant diagnostic evidence incomplete' => ['diagnostic_evidence_incomplete', 'Diagnostic evidence is incomplete', 'Collect a fresher or more complete diagnostic signal.', null],
|
|
||||||
'tenant retryable provider failure' => ['retryable_provider_failure', 'Provider failure looks retryable', 'Retry after the provider dependency recovers.', null],
|
|
||||||
'tenant manual handoff required' => ['manual_handoff_required', 'Manual support handoff required', 'Hand off the case with the current diagnostic summary and supporting references.', null],
|
|
||||||
]);
|
|
||||||
|
|
||||||
it('renders shared product knowledge in operation support diagnostics', function (
|
|
||||||
string $state,
|
|
||||||
string $headline,
|
|
||||||
string $safeNextAction,
|
|
||||||
?string $linkLabel,
|
|
||||||
): void {
|
|
||||||
[$user, , $run] = createProductKnowledgeSupportDiagnosticScenario($state);
|
|
||||||
|
|
||||||
$component = productKnowledgeOperationSupportDiagnosticsComponent($user, $run);
|
|
||||||
|
|
||||||
$component->mountAction('openSupportDiagnostics')
|
|
||||||
->assertMountedActionModalSee('Contextual help')
|
|
||||||
->assertMountedActionModalSee($headline)
|
|
||||||
->assertMountedActionModalSee($safeNextAction);
|
|
||||||
|
|
||||||
if ($linkLabel !== null) {
|
|
||||||
$component->assertMountedActionModalSee($linkLabel);
|
|
||||||
}
|
|
||||||
})->with([
|
|
||||||
'operation admin consent required' => ['admin_consent', 'Admin consent required', 'Grant admin consent and re-run verification.', 'Grant admin consent'],
|
|
||||||
'operation required permissions missing' => ['required_permissions', 'Required permissions missing', 'Open required permissions and confirm the missing grants.', 'Open required permissions'],
|
|
||||||
'operation connection unhealthy' => ['connection_unhealthy', 'Provider connection needs review', 'Review the provider connection before retrying.', null],
|
|
||||||
'operation verification failed' => ['verification_failed', 'Verification failed', 'Review the blocking reason and retry verification.', null],
|
|
||||||
'operation diagnostic evidence incomplete' => ['diagnostic_evidence_incomplete', 'Diagnostic evidence is incomplete', 'Collect a fresher or more complete diagnostic signal.', null],
|
|
||||||
'operation retryable provider failure' => ['retryable_provider_failure', 'Provider failure looks retryable', 'Retry after the provider dependency recovers.', null],
|
|
||||||
'operation manual handoff required' => ['manual_handoff_required', 'Manual support handoff required', 'Hand off the case with the current diagnostic summary and supporting references.', null],
|
|
||||||
]);
|
|
||||||
@ -1,171 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\System\Pages\Dashboard;
|
|
||||||
use App\Filament\System\Widgets\CustomerHealthKpis;
|
|
||||||
use App\Filament\System\Widgets\CustomerHealthTopWorkspaces;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
Filament::setCurrentPanel('system');
|
|
||||||
Filament::bootCurrentPanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows customer health widgets to authorized system users', function (): void {
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user, 'platform')
|
|
||||||
->get(Dashboard::getUrl(panel: 'system'))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSeeLivewire(CustomerHealthKpis::class)
|
|
||||||
->assertSeeLivewire(CustomerHealthTopWorkspaces::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the attention-needed widget hidden when no linked system detail surface is accessible', function (): void {
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user, 'platform')
|
|
||||||
->get(Dashboard::getUrl(panel: 'system'))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSeeLivewire(CustomerHealthKpis::class)
|
|
||||||
->assertDontSeeLivewire(CustomerHealthTopWorkspaces::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the attention-needed widget to operations-only users when operational rows are accessible', function (): void {
|
|
||||||
seedOperationalAttentionWorkspace('Ops Only Workspace');
|
|
||||||
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
PlatformCapabilities::OPERATIONS_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user, 'platform')
|
|
||||||
->get(Dashboard::getUrl(panel: 'system'))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSeeLivewire(CustomerHealthKpis::class)
|
|
||||||
->assertSeeLivewire(CustomerHealthTopWorkspaces::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the attention-needed widget to ops and runbooks users when operational rows are accessible', function (): void {
|
|
||||||
seedOperationalAttentionWorkspace('Runbooks Ops Workspace');
|
|
||||||
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
PlatformCapabilities::OPS_VIEW,
|
|
||||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user, 'platform')
|
|
||||||
->get(Dashboard::getUrl(panel: 'system'))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSeeLivewire(CustomerHealthKpis::class)
|
|
||||||
->assertSeeLivewire(CustomerHealthTopWorkspaces::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters directory-only attention rows out for operations-only users', function (): void {
|
|
||||||
seedOperationalAttentionWorkspace('Accessible Ops Workspace');
|
|
||||||
seedProviderAttentionWorkspace('Directory Only Workspace');
|
|
||||||
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
PlatformCapabilities::OPERATIONS_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user, 'platform')
|
|
||||||
->get(Dashboard::getUrl(panel: 'system'))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSeeLivewire(CustomerHealthKpis::class)
|
|
||||||
->assertSeeLivewire(CustomerHealthTopWorkspaces::class)
|
|
||||||
->assertSee('Accessible Ops Workspace')
|
|
||||||
->assertDontSee('Directory Only Workspace');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forbids customer health widgets when system dashboard access is denied', function (): void {
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user, 'platform')
|
|
||||||
->get(Dashboard::getUrl(panel: 'system'))
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
function seedOperationalAttentionWorkspace(string $workspaceName): void
|
|
||||||
{
|
|
||||||
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'name' => $workspaceName.' Tenant',
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => OperationRunStatus::Queued->value,
|
|
||||||
'outcome' => OperationRunOutcome::Pending->value,
|
|
||||||
'created_at' => now()->subHours(2),
|
|
||||||
'started_at' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedProviderAttentionWorkspace(string $workspaceName): void
|
|
||||||
{
|
|
||||||
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'name' => $workspaceName.' Tenant',
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
'is_enabled' => true,
|
|
||||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
|
||||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@ -1,186 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\System\Pages\Dashboard;
|
|
||||||
use App\Filament\System\Widgets\CustomerHealthKpis;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ProductUsageEvent;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
Filament::setCurrentPanel('system');
|
|
||||||
Filament::bootCurrentPanel();
|
|
||||||
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-04-27 12:00:00'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders aggregate healthy, warning, critical, and unknown counts with a visible time-basis cue', function (): void {
|
|
||||||
actingAsCustomerHealthSystemUser();
|
|
||||||
|
|
||||||
$baselineStats = customerHealthStats(Livewire::withQueryParams([
|
|
||||||
'window' => SystemConsoleWindow::LastDay,
|
|
||||||
])->test(CustomerHealthKpis::class));
|
|
||||||
|
|
||||||
seedCustomerHealthWorkspace('Healthy Workspace');
|
|
||||||
seedCustomerHealthWorkspace('Warning Workspace', recentUsage: false, historicalUsage: true);
|
|
||||||
seedCustomerHealthWorkspace('Critical Workspace', failedRun: true);
|
|
||||||
seedCustomerHealthWorkspace('Unknown Workspace', recentRun: false, readyReviewPack: false);
|
|
||||||
|
|
||||||
$stats = customerHealthStats(Livewire::withQueryParams([
|
|
||||||
'window' => SystemConsoleWindow::LastDay,
|
|
||||||
])->test(CustomerHealthKpis::class));
|
|
||||||
|
|
||||||
expect((int) $stats['Healthy']['value'] - (int) $baselineStats['Healthy']['value'])->toBe(1)
|
|
||||||
->and($stats['Healthy']['description'])->toBe('Operational stability, review-pack readiness, and engagement freshness honor Last 24 hours.')
|
|
||||||
->and((int) $stats['Warning']['value'] - (int) $baselineStats['Warning']['value'])->toBe(1)
|
|
||||||
->and($stats['Warning']['description'])->toBe('Onboarding readiness, provider health, and governance pressure stay point-in-time.')
|
|
||||||
->and((int) $stats['Critical']['value'] - (int) $baselineStats['Critical']['value'])->toBe(1)
|
|
||||||
->and((int) $stats['Unknown']['value'] - (int) $baselineStats['Unknown']['value'])->toBe(1)
|
|
||||||
->and($stats['Unknown']['description'])->toBe('Missing or stale inputs stay explicit instead of silently reading healthy.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers the customer health widget on the system dashboard for authorized users', function (): void {
|
|
||||||
$user = actingAsCustomerHealthSystemUser();
|
|
||||||
|
|
||||||
$response = $this->actingAs($user, 'platform')->get(Dashboard::getUrl(panel: 'system'));
|
|
||||||
|
|
||||||
$response->assertSuccessful()
|
|
||||||
->assertSee('Customer health')
|
|
||||||
->assertSeeLivewire(CustomerHealthKpis::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array{value:string,description:string|null}>
|
|
||||||
*/
|
|
||||||
function customerHealthStats($component): array
|
|
||||||
{
|
|
||||||
$method = new ReflectionMethod(CustomerHealthKpis::class, 'getStats');
|
|
||||||
$method->setAccessible(true);
|
|
||||||
|
|
||||||
return collect($method->invoke($component->instance()))
|
|
||||||
->mapWithKeys(fn (Stat $stat): array => [
|
|
||||||
(string) $stat->getLabel() => [
|
|
||||||
'value' => (string) $stat->getValue(),
|
|
||||||
'description' => $stat->getDescription(),
|
|
||||||
],
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
function actingAsCustomerHealthSystemUser(): PlatformUser
|
|
||||||
{
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
test()->actingAs($user, 'platform');
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{workspace: Workspace, tenant: Tenant}
|
|
||||||
*/
|
|
||||||
function seedCustomerHealthWorkspace(
|
|
||||||
string $workspaceName,
|
|
||||||
bool $readyReviewPack = true,
|
|
||||||
bool $recentUsage = true,
|
|
||||||
bool $historicalUsage = false,
|
|
||||||
bool $recentRun = true,
|
|
||||||
bool $failedRun = false,
|
|
||||||
): array {
|
|
||||||
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'name' => $workspaceName.' Tenant',
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($readyReviewPack) {
|
|
||||||
ReviewPack::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->ready()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'created_at' => now()->subHour(),
|
|
||||||
'generated_at' => now()->subHour(),
|
|
||||||
'expires_at' => now()->addDays(30),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($recentUsage) {
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'occurred_at' => now()->subMinutes(20),
|
|
||||||
]);
|
|
||||||
} elseif ($historicalUsage) {
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'occurred_at' => now()->subDays(3),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($recentRun) {
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => $failedRun ? OperationRunOutcome::Failed->value : OperationRunOutcome::Succeeded->value,
|
|
||||||
'created_at' => now()->subMinutes(10),
|
|
||||||
'started_at' => now()->subMinutes(15),
|
|
||||||
'completed_at' => now()->subMinutes(5),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Finding::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->closed()
|
|
||||||
->create([
|
|
||||||
'severity' => Finding::SEVERITY_LOW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace' => $workspace,
|
|
||||||
'tenant' => $tenant,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ProductUsageEvent;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-04-27 12:00:00'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a decision-first customer health card on the tenant detail page before diagnostics', function (): void {
|
|
||||||
$fixture = createCustomerHealthDecisionFixture('Tenant Decision Workspace');
|
|
||||||
$platformUser = createDirectoryPlatformUser();
|
|
||||||
|
|
||||||
$response = $this->actingAs($platformUser, 'platform')
|
|
||||||
->get(SystemDirectoryLinks::tenantDetail($fixture['tenant']).'?window='.SystemConsoleWindow::LastWeek)
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSee('Customer health decision')
|
|
||||||
->assertSee('Overall health')
|
|
||||||
->assertSee('Reason')
|
|
||||||
->assertSee('Impact')
|
|
||||||
->assertSee('Recommended next action')
|
|
||||||
->assertSee('Top driver: Provider connection health')
|
|
||||||
->assertSee('Default provider consent or verification is blocking reliable tenant management.')
|
|
||||||
->assertSee('Review connectivity signals below and confirm the default provider consent and verification state.')
|
|
||||||
->assertSee('Last 7 days');
|
|
||||||
|
|
||||||
$html = $response->getContent();
|
|
||||||
$decisionPosition = strpos($html, 'Customer health decision');
|
|
||||||
$diagnosticsPosition = strpos($html, 'Connectivity signals');
|
|
||||||
|
|
||||||
expect($decisionPosition)->not->toBeFalse()
|
|
||||||
->and($diagnosticsPosition)->not->toBeFalse()
|
|
||||||
->and($decisionPosition)->toBeLessThan($diagnosticsPosition);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a decision-first customer health card on the workspace detail page before tenant diagnostics', function (): void {
|
|
||||||
$fixture = createCustomerHealthDecisionFixture('Workspace Decision Workspace');
|
|
||||||
$platformUser = createDirectoryPlatformUser();
|
|
||||||
|
|
||||||
$response = $this->actingAs($platformUser, 'platform')
|
|
||||||
->get(SystemDirectoryLinks::workspaceDetail($fixture['workspace']).'?window='.SystemConsoleWindow::LastWeek)
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSee('Customer health decision')
|
|
||||||
->assertSee('Overall health')
|
|
||||||
->assertSee('Reason')
|
|
||||||
->assertSee('Impact')
|
|
||||||
->assertSee('Recommended next action')
|
|
||||||
->assertSee('Top driver: Provider connection health')
|
|
||||||
->assertSee('Default provider consent or verification is blocking reliable tenant management.')
|
|
||||||
->assertSee('Open the affected tenant below and review the default provider connection state.')
|
|
||||||
->assertSee('Last 7 days');
|
|
||||||
|
|
||||||
$html = $response->getContent();
|
|
||||||
$decisionPosition = strpos($html, 'Customer health decision');
|
|
||||||
$diagnosticsPosition = strpos($html, 'Tenants summary');
|
|
||||||
|
|
||||||
expect($decisionPosition)->not->toBeFalse()
|
|
||||||
->and($diagnosticsPosition)->not->toBeFalse()
|
|
||||||
->and($decisionPosition)->toBeLessThan($diagnosticsPosition);
|
|
||||||
});
|
|
||||||
|
|
||||||
function createDirectoryPlatformUser(): PlatformUser
|
|
||||||
{
|
|
||||||
return PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{workspace: Workspace, tenant: Tenant}
|
|
||||||
*/
|
|
||||||
function createCustomerHealthDecisionFixture(string $workspaceName): array
|
|
||||||
{
|
|
||||||
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'name' => $workspaceName.' Tenant',
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
'is_enabled' => true,
|
|
||||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
|
||||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ReviewPack::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->ready()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'created_at' => now()->subDays(3),
|
|
||||||
'generated_at' => now()->subDays(3),
|
|
||||||
'expires_at' => now()->addDays(30),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'occurred_at' => now()->subDays(3),
|
|
||||||
]);
|
|
||||||
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'created_at' => now()->subDays(3),
|
|
||||||
'started_at' => now()->subDays(3)->subMinutes(5),
|
|
||||||
'completed_at' => now()->subDays(3),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Finding::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->closed()
|
|
||||||
->create([
|
|
||||||
'severity' => Finding::SEVERITY_LOW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace' => $workspace,
|
|
||||||
'tenant' => $tenant,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\System\Widgets\CustomerHealthTopWorkspaces;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ProductUsageEvent;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
Filament::setCurrentPanel('system');
|
|
||||||
Filament::bootCurrentPanel();
|
|
||||||
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-04-27 12:00:00'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists the worst workspaces first with dominant reasons and one platform-safe next link per row', function (): void {
|
|
||||||
actingAsExplainabilitySystemUser();
|
|
||||||
|
|
||||||
$opsWorkspace = seedExplainabilityWorkspace('Alpha Operations');
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($opsWorkspace['tenant'])
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $opsWorkspace['workspace']->getKey(),
|
|
||||||
'status' => OperationRunStatus::Queued->value,
|
|
||||||
'outcome' => OperationRunOutcome::Pending->value,
|
|
||||||
'created_at' => now()->subHours(2),
|
|
||||||
'started_at' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$providerWorkspace = seedExplainabilityWorkspace('Beta Provider');
|
|
||||||
ProviderConnection::query()
|
|
||||||
->where('tenant_id', (int) $providerWorkspace['tenant']->getKey())
|
|
||||||
->update([
|
|
||||||
'is_enabled' => true,
|
|
||||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
|
||||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$viewData = customerHealthTopWorkspaceViewData(SystemConsoleWindow::LastWeek);
|
|
||||||
$rows = collect($viewData['rows'])->take(2)->values();
|
|
||||||
|
|
||||||
expect($rows[0]['workspace_label'])->toBe('Alpha Operations')
|
|
||||||
->and($rows[0]['overall']['label'])->toBe('Critical')
|
|
||||||
->and(array_column($rows[0]['dominant_dimensions'], 'label'))->toBe(['Operational stability'])
|
|
||||||
->and($rows[0]['next_link'])->toBe([
|
|
||||||
'label' => 'Open runs',
|
|
||||||
'url' => SystemOperationRunLinks::index(),
|
|
||||||
])
|
|
||||||
->and($rows[1]['workspace_label'])->toBe('Beta Provider')
|
|
||||||
->and($rows[1]['overall']['label'])->toBe('Critical')
|
|
||||||
->and(array_column($rows[1]['dominant_dimensions'], 'label'))->toBe(['Provider connection health'])
|
|
||||||
->and($rows[1]['next_link'])->toBe([
|
|
||||||
'label' => 'Review health details',
|
|
||||||
'url' => SystemDirectoryLinks::tenantDetail($providerWorkspace['tenant']).'?window='.SystemConsoleWindow::LastWeek,
|
|
||||||
])
|
|
||||||
->and($rows->every(fn (array $row): bool => count($row['dominant_dimensions']) <= 2))->toBeTrue()
|
|
||||||
->and($rows->every(fn (array $row): bool => ! str_contains($row['next_link']['url'], '/admin/')))->toBeTrue()
|
|
||||||
->and(array_key_exists('failed_count', $rows[0]))->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
function customerHealthTopWorkspaceViewData(string $window = SystemConsoleWindow::LastDay): array
|
|
||||||
{
|
|
||||||
$component = Livewire::withQueryParams([
|
|
||||||
'window' => $window,
|
|
||||||
])->test(CustomerHealthTopWorkspaces::class);
|
|
||||||
|
|
||||||
$method = new ReflectionMethod(CustomerHealthTopWorkspaces::class, 'getViewData');
|
|
||||||
$method->setAccessible(true);
|
|
||||||
|
|
||||||
return $method->invoke($component->instance());
|
|
||||||
}
|
|
||||||
|
|
||||||
function actingAsExplainabilitySystemUser(): PlatformUser
|
|
||||||
{
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
PlatformCapabilities::OPERATIONS_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
test()->actingAs($user, 'platform');
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{workspace: Workspace, tenant: Tenant}
|
|
||||||
*/
|
|
||||||
function seedExplainabilityWorkspace(string $workspaceName): array
|
|
||||||
{
|
|
||||||
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'name' => $workspaceName.' Tenant',
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ReviewPack::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->ready()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'created_at' => now()->subHour(),
|
|
||||||
'generated_at' => now()->subHour(),
|
|
||||||
'expires_at' => now()->addDays(30),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'occurred_at' => now()->subMinutes(20),
|
|
||||||
]);
|
|
||||||
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'created_at' => now()->subMinutes(10),
|
|
||||||
'started_at' => now()->subMinutes(15),
|
|
||||||
'completed_at' => now()->subMinutes(5),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Finding::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->closed()
|
|
||||||
->create([
|
|
||||||
'severity' => Finding::SEVERITY_LOW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace' => $workspace,
|
|
||||||
'tenant' => $tenant,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -6,8 +6,6 @@
|
|||||||
use App\Filament\System\Widgets\ControlTowerKpis;
|
use App\Filament\System\Widgets\ControlTowerKpis;
|
||||||
use App\Filament\System\Widgets\ControlTowerRecentFailures;
|
use App\Filament\System\Widgets\ControlTowerRecentFailures;
|
||||||
use App\Filament\System\Widgets\ControlTowerTopOffenders;
|
use App\Filament\System\Widgets\ControlTowerTopOffenders;
|
||||||
use App\Filament\System\Widgets\CustomerHealthKpis;
|
|
||||||
use App\Filament\System\Widgets\CustomerHealthTopWorkspaces;
|
|
||||||
use App\Filament\System\Widgets\ProductTelemetryKpis;
|
use App\Filament\System\Widgets\ProductTelemetryKpis;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
@ -74,23 +72,17 @@
|
|||||||
|
|
||||||
$widgets = $component->instance()->getWidgets();
|
$widgets = $component->instance()->getWidgets();
|
||||||
|
|
||||||
expect($widgets)->toHaveCount(7)
|
expect($widgets)->toHaveCount(5)
|
||||||
->and($widgets[1])->toBeInstanceOf(WidgetConfiguration::class)
|
->and($widgets[1])->toBeInstanceOf(WidgetConfiguration::class)
|
||||||
->and($widgets[2])->toBeInstanceOf(WidgetConfiguration::class)
|
->and($widgets[2])->toBeInstanceOf(WidgetConfiguration::class)
|
||||||
->and($widgets[3])->toBeInstanceOf(WidgetConfiguration::class)
|
->and($widgets[3])->toBeInstanceOf(WidgetConfiguration::class)
|
||||||
->and($widgets[4])->toBeInstanceOf(WidgetConfiguration::class)
|
->and($widgets[4])->toBeInstanceOf(WidgetConfiguration::class)
|
||||||
->and($widgets[5])->toBeInstanceOf(WidgetConfiguration::class)
|
->and($widgets[1]->widget)->toBe(ControlTowerKpis::class)
|
||||||
->and($widgets[6])->toBeInstanceOf(WidgetConfiguration::class)
|
->and($widgets[2]->widget)->toBe(ProductTelemetryKpis::class)
|
||||||
->and($widgets[1]->widget)->toBe(CustomerHealthKpis::class)
|
->and($widgets[3]->widget)->toBe(ControlTowerTopOffenders::class)
|
||||||
->and($widgets[2]->widget)->toBe(CustomerHealthTopWorkspaces::class)
|
->and($widgets[4]->widget)->toBe(ControlTowerRecentFailures::class)
|
||||||
->and($widgets[3]->widget)->toBe(ControlTowerKpis::class)
|
|
||||||
->and($widgets[4]->widget)->toBe(ProductTelemetryKpis::class)
|
|
||||||
->and($widgets[5]->widget)->toBe(ControlTowerTopOffenders::class)
|
|
||||||
->and($widgets[6]->widget)->toBe(ControlTowerRecentFailures::class)
|
|
||||||
->and($widgets[1]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
->and($widgets[1]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
||||||
->and($widgets[2]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
->and($widgets[2]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
||||||
->and($widgets[3]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
->and($widgets[3]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
||||||
->and($widgets[4]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
->and($widgets[4]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek]);
|
||||||
->and($widgets[5]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
|
||||||
->and($widgets[6]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek]);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -76,8 +76,7 @@
|
|||||||
->assertSee('Residual Directory Workspace')
|
->assertSee('Residual Directory Workspace')
|
||||||
->assertSee('Connectivity signals')
|
->assertSee('Connectivity signals')
|
||||||
->assertSee('Residual Default Connection')
|
->assertSee('Residual Default Connection')
|
||||||
->assertSee('Open in tenant admin')
|
->assertSee('Open in /admin')
|
||||||
->assertSee('Requires tenant admin membership.')
|
|
||||||
->assertSee(SystemDirectoryLinks::adminTenant($tenant), false)
|
->assertSee(SystemDirectoryLinks::adminTenant($tenant), false)
|
||||||
->assertSee('Open operations runs')
|
->assertSee('Open operations runs')
|
||||||
->assertSee(SystemOperationRunLinks::index(), false)
|
->assertSee(SystemOperationRunLinks::index(), false)
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Support\CustomerHealth\CustomerHealthDimensionCatalog;
|
|
||||||
|
|
||||||
it('exposes the fixed first-slice health dimensions and their time basis', function (): void {
|
|
||||||
$catalog = new CustomerHealthDimensionCatalog();
|
|
||||||
|
|
||||||
expect($catalog->names())->toBe([
|
|
||||||
CustomerHealthDimensionCatalog::ONBOARDING_READINESS,
|
|
||||||
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH,
|
|
||||||
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY,
|
|
||||||
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE,
|
|
||||||
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS,
|
|
||||||
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS,
|
|
||||||
])->and($catalog->visibleDimensions())->toBe([
|
|
||||||
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => 'Onboarding readiness',
|
|
||||||
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => 'Provider connection health',
|
|
||||||
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => 'Operational stability',
|
|
||||||
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => 'Governance pressure',
|
|
||||||
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => 'Review-pack readiness',
|
|
||||||
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => 'Engagement freshness',
|
|
||||||
])->and($catalog->isWindowed(CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY))->toBeTrue()
|
|
||||||
->and($catalog->isWindowed(CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS))->toBeTrue()
|
|
||||||
->and($catalog->isWindowed(CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS))->toBeTrue()
|
|
||||||
->and($catalog->isWindowed(CustomerHealthDimensionCatalog::ONBOARDING_READINESS))->toBeFalse()
|
|
||||||
->and($catalog->isWindowed(CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH))->toBeFalse()
|
|
||||||
->and($catalog->isWindowed(CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE))->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves the overall health level using the fixed severity precedence', function (): void {
|
|
||||||
$catalog = new CustomerHealthDimensionCatalog();
|
|
||||||
|
|
||||||
expect($catalog->resolveOverallLevel(['ok', 'unknown']))->toBe('unknown')
|
|
||||||
->and($catalog->resolveOverallLevel(['ok', 'warn', 'unknown']))->toBe('warn')
|
|
||||||
->and($catalog->resolveOverallLevel(['critical', 'ok']))->toBe('critical')
|
|
||||||
->and($catalog->resolveOverallLevel(['ok']))->toBe('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unknown customer health dimensions', function (): void {
|
|
||||||
$catalog = new CustomerHealthDimensionCatalog();
|
|
||||||
|
|
||||||
expect(fn () => $catalog->definition('customer_health.unknown'))
|
|
||||||
->toThrow(InvalidArgumentException::class, 'Unknown customer health dimension');
|
|
||||||
});
|
|
||||||
@ -1,414 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ProductUsageEvent;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantOnboardingSession;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\CustomerHealth\CustomerHealthDimensionCatalog;
|
|
||||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
|
||||||
use App\Support\Onboarding\OnboardingLifecycleState;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2025-02-15 12:00:00'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('derives workspace health from existing onboarding, provider, and telemetry truth', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create(['name' => 'Acme']);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'name' => 'Acme Production',
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
'is_enabled' => true,
|
|
||||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
|
||||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'occurred_at' => now()->subHours(2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Finding::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->closed()
|
|
||||||
->create([
|
|
||||||
'severity' => Finding::SEVERITY_LOW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$summary = summaryForWorkspace($workspace);
|
|
||||||
|
|
||||||
expect($summary['overall_level'])->toBe('critical')
|
|
||||||
->and($summary['dimensions'][CustomerHealthDimensionCatalog::ONBOARDING_READINESS]['level'])->toBe('ok')
|
|
||||||
->and($summary['dimensions'][CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH]['level'])->toBe('critical')
|
|
||||||
->and($summary['dimensions'][CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS]['level'])->toBe('ok')
|
|
||||||
->and($summary['dominant_dimension_keys'])->toBe([CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH, CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY, CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS])
|
|
||||||
->and($summary['next_link'])->toBe([
|
|
||||||
'label' => 'Review health details',
|
|
||||||
'url' => SystemDirectoryLinks::tenantDetail($tenant).'?window='.SystemConsoleWindow::LastDay,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies the selected window review-pack readiness rules', function (): void {
|
|
||||||
$unknownWorkspace = createWorkspaceWithActiveTenant('Unknown Workspace');
|
|
||||||
$warnWorkspace = createWorkspaceWithActiveTenant('Warn Workspace');
|
|
||||||
$okWorkspace = createWorkspaceWithActiveTenant('Ok Workspace');
|
|
||||||
$criticalWorkspace = createWorkspaceWithActiveTenant('Critical Workspace');
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($warnWorkspace['tenant'])
|
|
||||||
->forEvent(ProductUsageEventCatalog::REVIEW_PACK_REQUESTED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $warnWorkspace['workspace']->getKey(),
|
|
||||||
'occurred_at' => now()->subHours(1),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ReviewPack::factory()
|
|
||||||
->for($okWorkspace['tenant'])
|
|
||||||
->ready()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $okWorkspace['workspace']->getKey(),
|
|
||||||
'created_at' => now()->subHours(3),
|
|
||||||
'generated_at' => now()->subHours(2),
|
|
||||||
'expires_at' => now()->addDays(30),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ReviewPack::factory()
|
|
||||||
->for($criticalWorkspace['tenant'])
|
|
||||||
->failed()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $criticalWorkspace['workspace']->getKey(),
|
|
||||||
'created_at' => now()->subHours(2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$summaries = app(WorkspaceHealthSummaryQuery::class)
|
|
||||||
->summaries(SystemConsoleWindow::LastDay)
|
|
||||||
->keyBy('workspace_id');
|
|
||||||
|
|
||||||
expect($summaries[(int) $unknownWorkspace['workspace']->getKey()]['dimensions'][CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS]['level'])->toBe('unknown')
|
|
||||||
->and($summaries[(int) $warnWorkspace['workspace']->getKey()]['dimensions'][CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS]['level'])->toBe('warn')
|
|
||||||
->and($summaries[(int) $okWorkspace['workspace']->getKey()]['dimensions'][CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS]['level'])->toBe('ok')
|
|
||||||
->and($summaries[(int) $criticalWorkspace['workspace']->getKey()]['dimensions'][CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS]['level'])->toBe('critical');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the selected window for operations and engagement freshness', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create(['name' => 'Windowed Signals']);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
'name' => 'Windowed Tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'occurred_at' => now()->subDays(3),
|
|
||||||
]);
|
|
||||||
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Failed->value,
|
|
||||||
'created_at' => now()->subDays(2),
|
|
||||||
'started_at' => now()->subDays(2)->subMinutes(5),
|
|
||||||
'completed_at' => now()->subDays(2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$summary = summaryForWorkspace($workspace);
|
|
||||||
|
|
||||||
expect($summary['dimensions'][CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS]['level'])->toBe('warn')
|
|
||||||
->and($summary['dimensions'][CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY]['level'])->toBe('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps governance pressure explicit until governance truth exists', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create(['name' => 'Governance Signals']);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
'name' => 'Governance Tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ReviewPack::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->ready()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'created_at' => now()->subHour(),
|
|
||||||
'generated_at' => now()->subHour(),
|
|
||||||
'expires_at' => now()->addDays(30),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'occurred_at' => now()->subMinutes(20),
|
|
||||||
]);
|
|
||||||
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'created_at' => now()->subMinutes(10),
|
|
||||||
'started_at' => now()->subMinutes(15),
|
|
||||||
'completed_at' => now()->subMinutes(5),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$summaryWithoutGovernanceTruth = summaryForWorkspace($workspace);
|
|
||||||
|
|
||||||
expect($summaryWithoutGovernanceTruth['dimensions'][CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE]['level'])->toBe('unknown');
|
|
||||||
|
|
||||||
Finding::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->closed()
|
|
||||||
->create([
|
|
||||||
'severity' => Finding::SEVERITY_LOW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$summaryWithGovernanceTruth = summaryForWorkspace($workspace);
|
|
||||||
|
|
||||||
expect($summaryWithGovernanceTruth['dimensions'][CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE]['level'])->toBe('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores archived workspaces and archived tenant truth for active workspace summaries', function (): void {
|
|
||||||
$archivedWorkspace = Workspace::factory()->create([
|
|
||||||
'name' => 'Archived Workspace',
|
|
||||||
'archived_at' => now()->subDay(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$archivedWorkspaceTenant = Tenant::factory()->for($archivedWorkspace)->create([
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($archivedWorkspaceTenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $archivedWorkspace->getKey(),
|
|
||||||
'occurred_at' => now()->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$workspace = Workspace::factory()->create(['name' => 'Mixed Workspace']);
|
|
||||||
$activeTenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
'name' => 'Active Tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$archivedTenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'status' => Tenant::STATUS_ARCHIVED,
|
|
||||||
'name' => 'Archived Tenant',
|
|
||||||
'deleted_at' => now()->subDay(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($activeTenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($archivedTenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
'is_enabled' => true,
|
|
||||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
|
||||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$summaries = app(WorkspaceHealthSummaryQuery::class)->summaries();
|
|
||||||
$summary = $summaries->firstWhere('workspace_id', (int) $workspace->getKey());
|
|
||||||
|
|
||||||
expect($summaries->contains(fn (array $item): bool => $item['workspace_id'] === (int) $archivedWorkspace->getKey()))->toBeFalse()
|
|
||||||
->and($summary)->not->toBeNull()
|
|
||||||
->and($summary['dimensions'][CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH]['level'])->toBe('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sorts the attention list by severity, breadth, and name', function (): void {
|
|
||||||
$platformUser = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
PlatformCapabilities::OPERATIONS_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($platformUser, 'platform');
|
|
||||||
|
|
||||||
$criticalWorkspace = Workspace::factory()->create(['name' => 'Critical Workspace']);
|
|
||||||
$criticalTenant = Tenant::factory()->for($criticalWorkspace)->create([
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
'name' => 'Critical Tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($criticalTenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $criticalWorkspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($criticalTenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $criticalWorkspace->getKey(),
|
|
||||||
'status' => OperationRunStatus::Queued->value,
|
|
||||||
'outcome' => OperationRunOutcome::Pending->value,
|
|
||||||
'created_at' => now()->subHours(2),
|
|
||||||
'started_at' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$warnWorkspace = Workspace::factory()->create(['name' => 'Warn Workspace']);
|
|
||||||
$warnTenant = Tenant::factory()->for($warnWorkspace)->create([
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
'name' => 'Warn Tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($warnTenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $warnWorkspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($warnTenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $warnWorkspace->getKey(),
|
|
||||||
'occurred_at' => now()->subDays(5),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$unknownWorkspace = Workspace::factory()->create(['name' => 'Unknown Workspace']);
|
|
||||||
|
|
||||||
TenantOnboardingSession::factory()
|
|
||||||
->forWorkspace($unknownWorkspace)
|
|
||||||
->create([
|
|
||||||
'lifecycle_state' => OnboardingLifecycleState::Draft->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$attention = app(WorkspaceHealthSummaryQuery::class)
|
|
||||||
->attentionNeeded(SystemConsoleWindow::LastDay, 3)
|
|
||||||
->values();
|
|
||||||
|
|
||||||
expect($attention->pluck('workspace_name')->all())->toBe([
|
|
||||||
'Critical Workspace',
|
|
||||||
'Unknown Workspace',
|
|
||||||
'Warn Workspace',
|
|
||||||
])
|
|
||||||
->and($attention[0]['next_link'])->toBe([
|
|
||||||
'label' => 'Open runs',
|
|
||||||
'url' => SystemOperationRunLinks::index(),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{workspace: Workspace, tenant: Tenant}
|
|
||||||
*/
|
|
||||||
function createWorkspaceWithActiveTenant(string $workspaceName): array
|
|
||||||
{
|
|
||||||
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'name' => $workspaceName.' Tenant',
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace' => $workspace,
|
|
||||||
'tenant' => $tenant,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_name: string,
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>,
|
|
||||||
* non_ok_dimension_count: int,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
function summaryForWorkspace(Workspace $workspace): array
|
|
||||||
{
|
|
||||||
/** @var array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_name: string,
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>,
|
|
||||||
* non_ok_dimension_count: int,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* } $summary
|
|
||||||
*/
|
|
||||||
$summary = app(WorkspaceHealthSummaryQuery::class)
|
|
||||||
->summaryForWorkspace($workspace, SystemConsoleWindow::LastDay);
|
|
||||||
|
|
||||||
return $summary;
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
|
||||||
use App\Support\ProductKnowledge\ContextualHelpCatalog;
|
|
||||||
|
|
||||||
it('exposes the locked first-slice topic catalog and safe knowledge source', function (): void {
|
|
||||||
$catalog = new ContextualHelpCatalog();
|
|
||||||
|
|
||||||
expect($catalog->keys())->toBe([
|
|
||||||
ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED,
|
|
||||||
ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING,
|
|
||||||
ContextualHelpCatalog::CONNECTION_UNHEALTHY,
|
|
||||||
ContextualHelpCatalog::VERIFICATION_STALE,
|
|
||||||
ContextualHelpCatalog::VERIFICATION_FAILED,
|
|
||||||
ContextualHelpCatalog::DIAGNOSTIC_EVIDENCE_INCOMPLETE,
|
|
||||||
ContextualHelpCatalog::RETRYABLE_PROVIDER_FAILURE,
|
|
||||||
ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED,
|
|
||||||
])->and($catalog->definition(ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED))->toMatchArray([
|
|
||||||
'topic_key' => ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED,
|
|
||||||
'surface_families' => ['onboarding', 'support_diagnostics'],
|
|
||||||
'headline' => 'Admin consent required',
|
|
||||||
'safe_next_action' => 'Grant admin consent and re-run verification.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$knowledgeSource = $catalog->knowledgeSource();
|
|
||||||
$topicsByKey = collect($knowledgeSource['topics'])->keyBy('topic_key');
|
|
||||||
|
|
||||||
expect($knowledgeSource)->toMatchArray([
|
|
||||||
'version' => 1,
|
|
||||||
'topic_count' => 8,
|
|
||||||
])->and($topicsByKey->keys()->all())->toBe($catalog->keys())
|
|
||||||
->and($topicsByKey->get(ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED))->toMatchArray([
|
|
||||||
'headline' => 'Admin consent required',
|
|
||||||
'docs_links' => [
|
|
||||||
[
|
|
||||||
'label' => 'Grant admin consent',
|
|
||||||
'kind' => 'action',
|
|
||||||
'url' => null,
|
|
||||||
'resolver' => ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => 'Admin consent guide',
|
|
||||||
'kind' => 'docs',
|
|
||||||
'url' => RequiredPermissionsLinks::adminConsentGuideUrl(),
|
|
||||||
'resolver' => null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unknown contextual help topics', function (): void {
|
|
||||||
$catalog = new ContextualHelpCatalog();
|
|
||||||
|
|
||||||
expect(fn (): array => $catalog->definition('unknown-topic'))
|
|
||||||
->toThrow(InvalidArgumentException::class, 'Unknown contextual help topic');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps every machine-readable topic on the approved metadata surface only', function (): void {
|
|
||||||
$knowledgeSource = app(\App\Support\ProductKnowledge\ContextualHelpResolver::class)->knowledgeSource();
|
|
||||||
$allowedResolvers = [
|
|
||||||
null,
|
|
||||||
ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY,
|
|
||||||
ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($knowledgeSource['topics'] as $topic) {
|
|
||||||
expect(array_keys($topic))->toBe([
|
|
||||||
'topic_key',
|
|
||||||
'surface_families',
|
|
||||||
'headline',
|
|
||||||
'short_explanation',
|
|
||||||
'troubleshooting_steps',
|
|
||||||
'safe_next_action',
|
|
||||||
'glossary_terms',
|
|
||||||
'docs_links',
|
|
||||||
]);
|
|
||||||
|
|
||||||
foreach ($topic['docs_links'] as $link) {
|
|
||||||
expect(array_keys($link))->toBe(['label', 'kind', 'url', 'resolver'])
|
|
||||||
->and($link['resolver'])->toBeIn($allowedResolvers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Support\ProductKnowledge\ContextualHelpCatalog;
|
|
||||||
use App\Support\ProductKnowledge\ContextualHelpResolver;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
|
||||||
|
|
||||||
it('returns null for blank or unknown topic keys through the fallback contract', function (): void {
|
|
||||||
$resolver = app(ContextualHelpResolver::class);
|
|
||||||
|
|
||||||
expect($resolver->tryResolve(null))->toBeNull()
|
|
||||||
->and($resolver->tryResolve(''))->toBeNull()
|
|
||||||
->and($resolver->tryResolve('unknown-topic'))->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps dynamic link metadata but no tenant-specific url when tenant context is unavailable', function (): void {
|
|
||||||
$payload = app(ContextualHelpResolver::class)->resolve(ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING);
|
|
||||||
|
|
||||||
expect($payload['docs_links'][0])->toMatchArray([
|
|
||||||
'label' => 'Open required permissions',
|
|
||||||
'resolver' => ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS,
|
|
||||||
'url' => null,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes a safe machine-readable knowledge source without tenant or secret fields', function (): void {
|
|
||||||
$knowledgeSource = app(ContextualHelpResolver::class)->knowledgeSource();
|
|
||||||
$encoded = json_encode($knowledgeSource, JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
expect($encoded)->not->toContain('tenant_id')
|
|
||||||
->not->toContain('provider_connection_id')
|
|
||||||
->not->toContain('raw_response_body')
|
|
||||||
->not->toContain('credential')
|
|
||||||
->and($knowledgeSource['topic_count'])->toBe(8);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps explicit unmapped support-diagnostics reason codes on the null fallback path', function (): void {
|
|
||||||
$resolver = app(ContextualHelpResolver::class);
|
|
||||||
|
|
||||||
expect($resolver->topicKeyForSupportDiagnostics(
|
|
||||||
reasonCode: 'ext.support.manual_lookup_needed',
|
|
||||||
hasIncompleteEvidence: true,
|
|
||||||
runOutcome: null,
|
|
||||||
))->toBeNull()
|
|
||||||
->and($resolver->topicKeyForSupportDiagnostics(
|
|
||||||
reasonCode: ProviderReasonCodes::UnknownError,
|
|
||||||
hasIncompleteEvidence: true,
|
|
||||||
runOutcome: null,
|
|
||||||
))->toBe(ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED);
|
|
||||||
});
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\ProductKnowledge\ContextualHelpCatalog;
|
|
||||||
use App\Support\ProductKnowledge\ContextualHelpResolver;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
function contextualHelpTruthEnvelope(Tenant $tenant): ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
return new ArtifactTruthEnvelope(
|
|
||||||
artifactFamily: 'support_diagnostics',
|
|
||||||
artifactKey: 'tenant_support_diagnostics',
|
|
||||||
workspaceId: (int) $tenant->workspace_id,
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
executionOutcome: 'blocked',
|
|
||||||
artifactExistence: 'created',
|
|
||||||
contentState: 'partial',
|
|
||||||
freshnessState: 'current',
|
|
||||||
publicationReadiness: null,
|
|
||||||
supportState: 'supported',
|
|
||||||
actionability: 'required',
|
|
||||||
primaryLabel: 'Verification blocked',
|
|
||||||
primaryExplanation: 'Verification cannot continue until the prerequisite is resolved.',
|
|
||||||
diagnosticLabel: 'Admin consent required',
|
|
||||||
nextActionLabel: 'Retry verification',
|
|
||||||
nextActionUrl: null,
|
|
||||||
relatedRunId: null,
|
|
||||||
relatedArtifactUrl: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('resolves contextual help with reason translation, operator summary, and tenant-aware links', function (): void {
|
|
||||||
[, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
|
||||||
|
|
||||||
$payload = app(ContextualHelpResolver::class)->resolve(ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED, [
|
|
||||||
'tenant' => $tenant,
|
|
||||||
'reason_code' => ProviderReasonCodes::ProviderConsentMissing,
|
|
||||||
'artifact_truth' => contextualHelpTruthEnvelope($tenant),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect($payload)->toMatchArray([
|
|
||||||
'topic_key' => ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED,
|
|
||||||
'headline' => 'Admin consent required',
|
|
||||||
'short_explanation' => 'The provider connection cannot continue until admin consent is granted.',
|
|
||||||
'safe_next_action' => 'Retry verification',
|
|
||||||
'reason_label' => 'Admin consent required',
|
|
||||||
'diagnostic_code' => ProviderReasonCodes::ProviderConsentMissing,
|
|
||||||
])->and($payload['docs_links'][0])->toMatchArray([
|
|
||||||
'label' => 'Grant admin consent',
|
|
||||||
'resolver' => ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY,
|
|
||||||
'url' => RequiredPermissionsLinks::adminConsentGuideUrl(),
|
|
||||||
])->and($payload['operator_summary'])->toMatchArray([
|
|
||||||
'nextActionText' => 'Retry verification',
|
|
||||||
'diagnosticsAvailable' => true,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves required permissions links against the current tenant', function (): void {
|
|
||||||
[, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
|
||||||
|
|
||||||
$payload = app(ContextualHelpResolver::class)->resolve(ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING, [
|
|
||||||
'tenant' => $tenant,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect($payload['docs_links'][0])->toMatchArray([
|
|
||||||
'label' => 'Open required permissions',
|
|
||||||
'resolver' => ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS,
|
|
||||||
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps support diagnostics topics from shared reason and outcome signals', function (): void {
|
|
||||||
$resolver = app(ContextualHelpResolver::class);
|
|
||||||
|
|
||||||
expect($resolver->topicKeyForSupportDiagnostics(
|
|
||||||
reasonCode: ProviderReasonCodes::RateLimited,
|
|
||||||
hasIncompleteEvidence: false,
|
|
||||||
runOutcome: null,
|
|
||||||
))->toBe(ContextualHelpCatalog::RETRYABLE_PROVIDER_FAILURE)
|
|
||||||
->and($resolver->topicKeyForSupportDiagnostics(
|
|
||||||
reasonCode: ProviderReasonCodes::UnknownError,
|
|
||||||
hasIncompleteEvidence: false,
|
|
||||||
runOutcome: OperationRunOutcome::Failed->value,
|
|
||||||
))->toBe(ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED)
|
|
||||||
->and($resolver->topicKeyForSupportDiagnostics(
|
|
||||||
reasonCode: null,
|
|
||||||
hasIncompleteEvidence: false,
|
|
||||||
runOutcome: OperationRunOutcome::Failed->value,
|
|
||||||
))->toBe(ContextualHelpCatalog::VERIFICATION_FAILED)
|
|
||||||
->and($resolver->topicKeyForSupportDiagnostics(
|
|
||||||
reasonCode: null,
|
|
||||||
hasIncompleteEvidence: true,
|
|
||||||
runOutcome: null,
|
|
||||||
))->toBe(ContextualHelpCatalog::DIAGNOSTIC_EVIDENCE_INCOMPLETE);
|
|
||||||
});
|
|
||||||
@ -5,7 +5,7 @@ # Spec Candidates
|
|||||||
>
|
>
|
||||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||||
|
|
||||||
> **Last reviewed**: 2026-04-27 (added Audited Support Sessions / Assisted Tenant Access and Audience-Aware Decision Surface Adoption Closure; retained Product Scalability & Self-Service Foundation, Codebase Quality & Engineering Maturity cluster, Microsoft-first provider-extensible Decision-Based Operating candidates, and Private AI Execution & Usage Governance Foundation candidates)
|
> **Last reviewed**: 2026-04-25 (added Product Scalability & Self-Service Foundation candidates, Additional Solo-Founder Scale Guardrails candidates, Microsoft-first provider-extensible Decision-Based Operating candidates, and Private AI Execution & Usage Governance Foundation candidates; retained Codebase Quality & Engineering Maturity cluster and existing strategic hardening lanes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -725,46 +725,6 @@ ### System Panel Least-Privilege Capability Model
|
|||||||
- **Strategic sequencing**: First item in this cluster because it is the only finding with direct enterprise security / least-privilege implications.
|
- **Strategic sequencing**: First item in this cluster because it is the only finding with direct enterprise security / least-privilege implications.
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
### Audited Support Sessions / Assisted Tenant Access
|
|
||||||
- **Type**: security hardening / platform-plane-to-tenant-plane access boundary
|
|
||||||
- **Source**: product candidate 2026-04-27 — explicit separation between Platform Control Plane (`/system`) and Customer/Tenant Admin Plane (`/admin`)
|
|
||||||
- **Problem**: Platform operators sometimes need tenant-context visibility for support, but the current control-plane/admin-plane split makes a plain `Open in /admin` affordance misleading for platform-only users. Granting permanent tenant memberships or hidden cross-tenant superuser access would solve the support friction in the wrong way by collapsing least privilege, auditability, and customer trust.
|
|
||||||
- **Why it matters**: TenantPilot needs an enterprise-safe answer to "how can support look at a tenant?" The answer cannot be silent impersonation or blanket `/admin` access. It must be explicit, tenant-scoped, reason-bound, time-limited, visible in the UI, and fully auditable.
|
|
||||||
- **Proposed direction**:
|
|
||||||
- introduce a `support_sessions` model bound to one workspace/tenant, one platform user, one mode, one reason, and one expiry
|
|
||||||
- allow support sessions to be started only from `/system`, never implicitly from `/admin`
|
|
||||||
- make read-only the default and smallest promotable slice; keep elevated support as a separately capability-gated follow-up or tightly bounded extension
|
|
||||||
- show a persistent non-dismissible `/admin` banner with workspace/tenant, platform user, mode, reason, expiry, and `End session`
|
|
||||||
- thread `support_session_id` and support-context metadata into audit events and any allowed elevated mutations
|
|
||||||
- replace ambiguous `Open in /admin` affordances with decision-based copy: real tenant membership -> `Open in tenant admin`; support-capable platform user -> `Start support session`; otherwise -> `Admin access requires tenant membership`
|
|
||||||
- enforce expiry and scope server-side so a session cannot cross tenants or remain valid after `expires_at`
|
|
||||||
- **Candidate capabilities**:
|
|
||||||
- `platform.support_sessions.view`
|
|
||||||
- `platform.support_sessions.start`
|
|
||||||
- `platform.support_sessions.end`
|
|
||||||
- `platform.support_sessions.end_any`
|
|
||||||
- `platform.support_sessions.elevate`
|
|
||||||
- `platform.support_sessions.audit_view`
|
|
||||||
- **Scope boundaries**:
|
|
||||||
- **In scope**: support-session model/status lifecycle, required reason, duration defaults/limits, start/end/expiry workflow, visible `/admin` support banner, read-only enforcement for support sessions, audit events, link/CTA semantics, and tests for isolation and expiry
|
|
||||||
- **Out of scope**: silent impersonation as a tenant user, permanent platform-user tenant memberships, Entra-side membership automation, full customer approval workflow, recording/screenshots, or broad privileged write access by default
|
|
||||||
- **Acceptance points**:
|
|
||||||
- a platform user still has no automatic `/admin` access without real tenant membership or an active support session
|
|
||||||
- a support-capable platform user can start a time-limited session for one target workspace/tenant with a mandatory reason
|
|
||||||
- read-only support sessions can open tenant/admin pages and inspect relevant records but cannot trigger mutations such as restore, settings change, exception creation, membership change, or other tenant-changing actions
|
|
||||||
- the `/admin` banner remains visible for the full session and shows enough information to make the access state unmistakable
|
|
||||||
- ended or expired sessions immediately lose access server-side and emit explicit audit events
|
|
||||||
- tests prove a support session cannot be reused for another tenant or hidden behind UI-only checks
|
|
||||||
- **Risks / open questions**:
|
|
||||||
- v1 should likely stay read-only to avoid collapsing this candidate into a broader privileged-write workflow
|
|
||||||
- Elevated support may be necessary later, but it needs its own capability, shorter expiry, stronger audit semantics, and likely separate prioritization
|
|
||||||
- The product must decide whether some diagnostics/support surfaces belong in `/system` instead of requiring tenant-plane access at all
|
|
||||||
- Support-session audit records must remain visibly distinct from real customer-user actions
|
|
||||||
- **Dependencies**: System Panel Least-Privilege Capability Model, tenant/admin access boundary, platform-user capability model, audit log foundation, workspace/tenant scoping helpers, support-diagnostics surfaces
|
|
||||||
- **Related specs / candidates**: Support Diagnostic Pack, In-App Support Request with Context, Operational Controls & Feature Flags, Security Trust Pack Light, platform superadmin / break-glass rules
|
|
||||||
- **Strategic sequencing**: Immediately after System Panel least-privilege hardening. First shrink coarse platform visibility, then introduce an explicit audited bridge for rare tenant-context support access instead of relying on hidden superuser semantics.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Static Analysis Baseline for Platform Code
|
### Static Analysis Baseline for Platform Code
|
||||||
- **Type**: quality gate / developer experience hardening
|
- **Type**: quality gate / developer experience hardening
|
||||||
- **Source**: full codebase quality audit 2026-04-25 — the repo has strong Pest and lane-based tests but no visible PHPStan/Larastan/Psalm/Rector gate
|
- **Source**: full codebase quality audit 2026-04-25 — the repo has strong Pest and lane-based tests but no visible PHPStan/Larastan/Psalm/Rector gate
|
||||||
@ -914,13 +874,12 @@ ### RestoreService Responsibility Split
|
|||||||
|
|
||||||
> Recommended sequence for this cluster:
|
> Recommended sequence for this cluster:
|
||||||
> 1. **System Panel Least-Privilege Capability Model**
|
> 1. **System Panel Least-Privilege Capability Model**
|
||||||
> 2. **Audited Support Sessions / Assisted Tenant Access**
|
> 2. **Static Analysis Baseline for Platform Code**
|
||||||
> 3. **Static Analysis Baseline for Platform Code**
|
> 3. **Architecture Boundary Guard Tests**
|
||||||
> 4. **Architecture Boundary Guard Tests**
|
> 4. **Filament Hotspot Decomposition Foundation**
|
||||||
> 5. **Filament Hotspot Decomposition Foundation**
|
> 5. **RestoreService Responsibility Split**
|
||||||
> 6. **RestoreService Responsibility Split**
|
|
||||||
>
|
>
|
||||||
> Why this order: first close the coarse platform-capability gap, then add an explicit audited bridge for rare tenant-context support access, then add quality gates, then protect architecture boundaries, and only then start behavior-preserving decomposition of the largest UI/service hotspots. This avoids a broad rewrite while directly addressing the audit's highest-leverage security and maintainability risks.
|
> Why this order: first close the enterprise security/least-privilege gap, then add quality gates, then protect architecture boundaries, and only then start behavior-preserving decomposition of the largest UI/service hotspots. This avoids a broad rewrite while directly addressing the audit's highest-leverage risks.
|
||||||
|
|
||||||
|
|
||||||
> Platform Hardening — OperationRun UX Consistency cluster: these candidates prevent OperationRun-starting features from drifting into surface-local UX behavior. The goal is not to rebuild the Operations Hub, progress system, or notification architecture in one step. The immediate priority is to make OperationRun start UX contract-driven so new features cannot hand-roll local toasts, operation links, browser events, and queued-notification decisions independently.
|
> Platform Hardening — OperationRun UX Consistency cluster: these candidates prevent OperationRun-starting features from drifting into surface-local UX behavior. The goal is not to rebuild the Operations Hub, progress system, or notification architecture in one step. The immediate priority is to make OperationRun start UX contract-driven so new features cannot hand-roll local toasts, operation links, browser events, and queued-notification decisions independently.
|
||||||
@ -1653,153 +1612,6 @@ ### Surface Taxonomy & Workflow-First IA Classification
|
|||||||
- **Dependencies**: Decision-First Operating Constitution Hardening, existing navigation/context/action-surface specs, product surface inventory
|
- **Dependencies**: Decision-First Operating Constitution Hardening, existing navigation/context/action-surface specs, product surface inventory
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
### Audience-Aware Decision Surface Adoption Closure
|
|
||||||
- **Priority**: P0
|
|
||||||
- **Type**: UX architecture adoption / platform hardening
|
|
||||||
- **Roadmap fit**: Cross-cutting platform quality, customer-readiness, MSP operator UX, customer read-only foundation
|
|
||||||
- **Depends on**: Existing `EnterpriseDetail`, `OperatorExplanation`, `GovernanceArtifactTruth`, `VerificationReportViewer`, `SupportDiagnosticBundle`, RBAC/capability model
|
|
||||||
- **Do not build**: A parallel UI framework
|
|
||||||
- **Problem**: TenantPilot already has strong shared UI foundations for decision-grade detail pages, governance artifact truth, operator explanations, verification reports, and support diagnostics, but adoption is inconsistent across the platform. Several operational and governance pages still expose too much internal diagnostic, lifecycle, context, reason, JSON, and support/debug information in the default reading path. The recurring issue is not missing data quality. It is information hierarchy: decision content, diagnostics, evidence, and raw support/debug payloads are often rendered as equal-priority blocks.
|
|
||||||
- **Goal**: Harden the existing decision-first UI system by introducing audience-aware disclosure rules and applying them to the highest-risk surfaces first. Default pages should become customer-readable while retaining full operator and support depth through progressive disclosure and role/capability gates.
|
|
||||||
- **Non-goals**:
|
|
||||||
- Do not introduce a new UI framework.
|
|
||||||
- Do not replace `EnterpriseDetail`.
|
|
||||||
- Do not remove diagnostics or raw evidence.
|
|
||||||
- Do not weaken auditability.
|
|
||||||
- Do not hide evidence from authorized operators/support users.
|
|
||||||
- Do not redesign the whole platform in one pass.
|
|
||||||
- Do not migrate every page in this spec.
|
|
||||||
- **Existing foundations to reuse**:
|
|
||||||
- `EnterpriseDetail`
|
|
||||||
- `OperatorExplanation`
|
|
||||||
- `GovernanceArtifactTruth`
|
|
||||||
- `VerificationReportViewer`
|
|
||||||
- `SupportDiagnosticBundle`
|
|
||||||
- existing Filament-native Sections/Infolists/Actions/Tabs/Accordions
|
|
||||||
- **Reuse rule**: Any new helper must be a small visibility/disclosure layer, not a competing rendering system.
|
|
||||||
- **First slice**:
|
|
||||||
1. `OperationRun` viewer
|
|
||||||
2. Managed Tenant Onboarding verify step
|
|
||||||
- **Requirements**:
|
|
||||||
- **Decision surface**: each target page MUST show a clear default decision surface with status, reason, impact, primary next action, one dominant CTA, optional one secondary CTA, and a short artifact/result summary.
|
|
||||||
- **Diagnostics**: lifecycle, timing, related operation, verification-report detail, drift/report detail, supporting evidence, and provider diagnostic summaries MUST be secondary, collapsed, tabbed, or visually lower priority.
|
|
||||||
- **Support / raw evidence**: raw JSON, context payloads, fingerprints, reason owner, platform reason family, viewer context, tenant selector context, monitoring detail, and copy/show-raw actions MUST NOT appear in the default customer-readable path. They must be collapsed and capability-gated where applicable.
|
|
||||||
- **Audience modes**: the target surfaces MUST distinguish customer/read-only default, operator/MSP diagnostics, and platform/support raw evidence. Customer/read-only users MUST NOT see internal debug semantics by default. Operators MAY expand diagnostics. Support/platform users MAY access raw evidence when authorized.
|
|
||||||
- **One primary action**: each target surface MUST expose only one dominant next action. Secondary links such as `Open operation`, `View tenant`, `Technical details`, or `Show JSON` must not visually compete with the primary remediation action.
|
|
||||||
- **No duplicate truth**: the same blocker, reason, or next action MUST NOT be repeated across multiple visible cards. If the dominant blocker is `Admin consent required`, the page may show it once in the decision surface and then provide supporting evidence in diagnostics, but it must not repeat that message in readiness, permission diagnostics, contextual help, verification summary, and issue lists at equal priority.
|
|
||||||
- **OperationRun viewer target state**:
|
|
||||||
- **Default visible**: operation status/outcome, human-readable reason, customer-readable impact, primary next action, artifact/result summary, limited actions
|
|
||||||
- **Secondary / collapsed**: lifecycle, timings, related context, support diagnostics, verification/drift/report internals
|
|
||||||
- **Support/raw gated**: raw context, JSON, fingerprints, reason ownership, platform reason family, monitoring detail
|
|
||||||
- **Managed Tenant Onboarding verify-step target state**:
|
|
||||||
- **Default visible**: onboarding readiness, current step, status, dominant blocker, primary next action, supporting evidence links
|
|
||||||
- **Secondary**: verification summary, required permissions, operation link, technical details
|
|
||||||
- **Hidden / fallback**: permission diagnostics should be visible only as fallback when no stored verification report is available. Once a verification report exists, permission details move into supporting evidence or technical details.
|
|
||||||
- **Acceptance criteria**:
|
|
||||||
- `OperationRun` viewer default path is readable in under 5 seconds.
|
|
||||||
- `OperationRun` viewer shows one dominant next action.
|
|
||||||
- `OperationRun` viewer default path does not show raw JSON, raw context, fingerprints, reason owner, platform reason family, or monitoring detail.
|
|
||||||
- `OperationRun` diagnostics remain accessible to authorized operators.
|
|
||||||
- `OperationRun` raw/support details are collapsed and capability-gated where applicable.
|
|
||||||
- Managed Tenant Onboarding verify step shows exactly one primary next action.
|
|
||||||
- Managed Tenant Onboarding verify step does not duplicate permission/consent blockers across readiness, diagnostics, contextual help, and report sections.
|
|
||||||
- Permission diagnostics are hidden when a stored verification report exists and visible only as fallback when no report exists.
|
|
||||||
- `Current checkpoint` or other internal lifecycle wording is replaced with customer/operator-friendly wording such as `Step`.
|
|
||||||
- Duplicate visible headings such as `Verification report / Verification report` are removed.
|
|
||||||
- Existing support diagnostics and verification report data remain available.
|
|
||||||
- **Required tests**:
|
|
||||||
- focused Pest coverage proving the default view shows status, reason, impact, and primary next action
|
|
||||||
- internal debug semantics are not default-visible
|
|
||||||
- raw JSON/context/fingerprints are collapsed or gated
|
|
||||||
- customer/read-only role does not see support/raw details by default
|
|
||||||
- operator role can access diagnostics
|
|
||||||
- support/platform role can access raw evidence where authorized
|
|
||||||
- onboarding verify step renders one primary action
|
|
||||||
- onboarding permission diagnostics are fallback-only when a verification report exists
|
|
||||||
- no duplicate visible decision headings exist
|
|
||||||
- **Implementation notes**:
|
|
||||||
- Prefer small, composable changes.
|
|
||||||
- Add a visibility/disclosure helper only if existing policies are insufficient.
|
|
||||||
- Extend existing EnterpriseDetail/Verification/Support surfaces instead of replacing them.
|
|
||||||
- Reduce one-off Blade/Tailwind cards where shared patterns can express the same concept.
|
|
||||||
- Preserve auditability and evidence depth.
|
|
||||||
- Preserve existing RBAC/capability enforcement.
|
|
||||||
- Preserve Livewire v4 and Filament v5 conventions.
|
|
||||||
- **Out of scope**:
|
|
||||||
- full platform-wide migration
|
|
||||||
- customer read-only portal implementation
|
|
||||||
- PDF/export redesign
|
|
||||||
- redesign of all Findings/Baseline/Evidence pages
|
|
||||||
- new AI explanation features
|
|
||||||
- new support ticketing workflow
|
|
||||||
|
|
||||||
> Later / dependent candidates: after the adoption-closure slice above is specified and implemented, keep the next migrations explicitly dependent instead of bundling them into the same P0 effort.
|
|
||||||
|
|
||||||
#### Later / dependent candidates
|
|
||||||
|
|
||||||
##### Findings & Risk Acceptance Decision Surface Migration
|
|
||||||
- **Priority**: P1
|
|
||||||
- **Type**: pattern adoption / workflow UX consolidation
|
|
||||||
- **Depends on**: Audience-Aware Decision Surface Adoption Closure
|
|
||||||
- **Roadmap fit**: R1.5 Findings Workflow V2, R1.6 Exceptions / Risk Acceptance, R2 Customer Read-only View
|
|
||||||
- **Problem**: Finding and Finding Exception detail pages currently expose workflow status, ownership, severity, timestamps, exception state, and remediation context through local Filament sections. These pages are functional, but they do not consistently use the shared decision-first detail patterns. As Findings and Risk Acceptance become customer-facing governance workflows, the default view must clearly separate risk decision, owner/action, diagnostic detail, and evidence/support context.
|
|
||||||
- **Goal**: Migrate Finding and Finding Exception detail pages toward the shared decision surface model.
|
|
||||||
- **First slice**:
|
|
||||||
- Finding detail page
|
|
||||||
- Finding Exception detail page
|
|
||||||
- **Requirements**:
|
|
||||||
- default view shows risk status, severity, reason, impact, owner, due date, and next action
|
|
||||||
- workflow actions are clearly prioritized
|
|
||||||
- risk acceptance state is shown as a decision state, not just metadata
|
|
||||||
- evidence and occurrence history are secondary
|
|
||||||
- raw/internal context is hidden or collapsed
|
|
||||||
- customer/read-only users see risk posture and accepted-risk status without internal debug semantics
|
|
||||||
- **Acceptance criteria**:
|
|
||||||
- Finding detail page has one clear primary action based on status
|
|
||||||
- Finding Exception page clearly shows accepted risk, owner, expiry, scope, and renewal/expiry state
|
|
||||||
- evidence/history is secondary
|
|
||||||
- no raw/debug payload appears in the default customer-readable view
|
|
||||||
|
|
||||||
##### Baseline & Drift Decision Surface Migration
|
|
||||||
- **Priority**: P1
|
|
||||||
- **Type**: pattern adoption / governance UX consolidation
|
|
||||||
- **Depends on**: Audience-Aware Decision Surface Adoption Closure
|
|
||||||
- **Roadmap fit**: R1 Golden Master Governance, R1.4 Drift UI, R2 Reports/Evidence
|
|
||||||
- **Problem**: Baseline and Drift pages contain decision-grade governance data but can become dense because comparison state, drift findings, policy metadata, diagnostics, and technical evidence compete in the same reading path.
|
|
||||||
- **Goal**: Apply the decision-first hierarchy to Baseline Profile, Baseline Snapshot, and Baseline Compare surfaces.
|
|
||||||
- **First slice**:
|
|
||||||
- Baseline Profile view
|
|
||||||
- Baseline Compare landing / matrix summary
|
|
||||||
- **Requirements**:
|
|
||||||
- default view shows baseline status, last compare result, drift summary, severity, and primary next action
|
|
||||||
- detailed matrix data is secondary
|
|
||||||
- technical comparison diagnostics are collapsed or moved behind filters/tabs
|
|
||||||
- customer-readable summaries are separated from operator investigation tools
|
|
||||||
- raw comparison context and fingerprints are not default-visible
|
|
||||||
- **Acceptance criteria**:
|
|
||||||
- Baseline Profile view shows one primary governance action
|
|
||||||
- Baseline Compare default view summarizes drift before showing the dense matrix
|
|
||||||
- drift details remain accessible to operators
|
|
||||||
- raw/fingerprint-level data is hidden or support-only
|
|
||||||
|
|
||||||
##### Customer Read-only Decision Views
|
|
||||||
- **Priority**: P2
|
|
||||||
- **Type**: customer-facing UX / access model
|
|
||||||
- **Depends on**: Audience-Aware Decision Surface Adoption Closure, Findings & Risk Acceptance Migration, Reports/Evidence foundation
|
|
||||||
- **Roadmap fit**: R2.6 Customer Read-only View v1
|
|
||||||
- **Problem**: TenantPilot has strong operator and support detail surfaces, but customer-facing users need a calmer read-only experience focused on governance status, accepted risks, findings, evidence summaries, and review outcomes.
|
|
||||||
- **Goal**: Introduce customer read-only views that reuse the same decision-surface contracts but hide operator diagnostics and support/raw internals by default.
|
|
||||||
- **Requirements**:
|
|
||||||
- customers see baseline status, findings, accepted risks, reports, and evidence summaries
|
|
||||||
- customers do not see raw JSON, internal reason ownership, fingerprints, platform reason families, or debug context
|
|
||||||
- customers can open review-ready explanations and evidence summaries
|
|
||||||
- admin/operator actions are hidden
|
|
||||||
- support/operator users keep access to deeper diagnostics
|
|
||||||
- **Acceptance criteria**:
|
|
||||||
- customer members can understand tenant posture without technical debug details
|
|
||||||
- customer views remain audit-friendly but not overwhelming
|
|
||||||
- all customer-facing detail pages follow decision-first, diagnostics-second, evidence-third
|
|
||||||
|
|
||||||
### Personal Work IA / My Work
|
### Personal Work IA / My Work
|
||||||
- **Type**: IA / workflow foundation
|
- **Type**: IA / workflow foundation
|
||||||
- **Source**: admin workspace IA discussion 2026-04-21; personal work architecture candidate pack
|
- **Source**: admin workspace IA discussion 2026-04-21; personal work architecture candidate pack
|
||||||
|
|||||||
@ -4,7 +4,7 @@ # Product Standards
|
|||||||
> Specs reference these standards; they do not redefine them.
|
> Specs reference these standards; they do not redefine them.
|
||||||
> Guard tests enforce critical constraints automatically.
|
> Guard tests enforce critical constraints automatically.
|
||||||
|
|
||||||
**Last reviewed**: 2026-04-27
|
**Last reviewed**: 2026-04-24
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -42,7 +42,7 @@ ## Related Docs
|
|||||||
|
|
||||||
| Document | Location | Purpose |
|
| Document | Location | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Constitution | `.specify/memory/constitution.md` | Permanent principles (PROP-001, BLOAT-001, OPS-UX-START-001, UI-CONST-001, DECIDE-001, DECIDE-AUD-001, UI-SURF-001, ACTSURF-001, UI-HARD-001, UI-EX-001, HDR-001, OPSURF-001, UI-FIL-001, UX-001, Action Surface Contract, RBAC-UX) |
|
| Constitution | `.specify/memory/constitution.md` | Permanent principles (PROP-001, BLOAT-001, OPS-UX-START-001, UI-CONST-001, DECIDE-001, UI-SURF-001, ACTSURF-001, UI-HARD-001, UI-EX-001, HDR-001, OPSURF-001, UI-FIL-001, UX-001, Action Surface Contract, RBAC-UX) |
|
||||||
| Product Principles | `docs/product/principles.md` | High-level product decisions |
|
| Product Principles | `docs/product/principles.md` | High-level product decisions |
|
||||||
| Table Rollout Audit | `docs/ui/filament-table-standard.md` | Rollout inventory and implementation state from Spec 125 |
|
| Table Rollout Audit | `docs/ui/filament-table-standard.md` | Rollout inventory and implementation state from Spec 125 |
|
||||||
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |
|
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
# Specification Quality Checklist: Product Knowledge & Contextual Help
|
|
||||||
|
|
||||||
**Purpose**: Validate specification completeness and quality before proceeding to implementation planning
|
|
||||||
**Created**: 2026-04-26
|
|
||||||
**Feature**: [spec.md](../spec.md)
|
|
||||||
|
|
||||||
## Content Quality
|
|
||||||
|
|
||||||
- [x] Business value and operator outcomes stay explicit
|
|
||||||
- [x] Implementation anchors are intentional and bounded to existing repo surfaces
|
|
||||||
- [x] Runtime-governance sections are present for an implementation-ready spec package
|
|
||||||
- [x] All mandatory sections completed
|
|
||||||
|
|
||||||
## Requirement Completeness
|
|
||||||
|
|
||||||
- [x] No `[NEEDS CLARIFICATION]` markers remain
|
|
||||||
- [x] Requirements are testable and unambiguous
|
|
||||||
- [x] Success criteria are measurable
|
|
||||||
- [x] Acceptance scenarios are defined for the primary user journeys
|
|
||||||
- [x] Edge cases are identified
|
|
||||||
- [x] Scope is clearly bounded to onboarding and support-diagnostic surface families plus one internal machine-readable knowledge source deliverable
|
|
||||||
- [x] Dependencies and assumptions are identified
|
|
||||||
|
|
||||||
## Feature Readiness
|
|
||||||
|
|
||||||
- [x] The first slice is small enough for a bounded implementation loop
|
|
||||||
- [x] The plan identifies the concrete repo surfaces likely to change
|
|
||||||
- [x] The tasks are ordered, testable, and grouped by user story
|
|
||||||
- [x] No unresolved product question blocks safe implementation of the first slice
|
|
||||||
|
|
||||||
## Governance Readiness
|
|
||||||
|
|
||||||
- [x] No new persistence is introduced without justification
|
|
||||||
- [x] Provider-boundary handling and glossary reuse are explicit
|
|
||||||
- [x] Existing RBAC and tenant/workspace isolation remain authoritative
|
|
||||||
- [x] Operator-facing surface changes include the required UI contract sections
|
|
||||||
- [x] Livewire v4 compliance, unchanged provider registration location, no global-search changes, no destructive-action additions, and no asset-strategy changes are explicit in the package
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- This checklist completes the implementation-ready package alongside `spec.md`, `plan.md`, and `tasks.md`.
|
|
||||||
- The active slice stays bounded to one code-owned help catalog, one resolver, two adopted surface families, and one safe machine-readable knowledge source.
|
|
||||||
@ -1,199 +0,0 @@
|
|||||||
# Implementation Plan: Product Knowledge & Contextual Help
|
|
||||||
|
|
||||||
**Branch**: `244-product-knowledge-contextual-help` | **Date**: 2026-04-26 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/spec.md`
|
|
||||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/spec.md`
|
|
||||||
|
|
||||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- Add a bounded `ProductKnowledge` support namespace with one code-owned contextual-help catalog and one resolver that derive help payloads from existing glossary, reason-translation, operator-explanation, and docs-link helpers.
|
|
||||||
- Adopt that resolver on two existing high-value surfaces only: `ManagedTenantOnboardingWizard` and the support-diagnostic bundle used by tenant and operation-context previews.
|
|
||||||
- Expose the same catalog as a safe machine-readable knowledge source for later internal AI/support use, while keeping the slice read-only, non-persistent, Livewire v4-compatible, and free of panel/provider/global-search/asset changes.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
|
||||||
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `PlatformVocabularyGlossary`, `ReasonPresenter`, `OperatorExplanationBuilder`, `SupportDiagnosticBundleBuilder`, `RequiredPermissionsLinks`, `ProviderReasonTranslator`, and `ManagedTenantOnboardingWizard`
|
|
||||||
**Storage**: N/A - no new database or persisted product-knowledge truth
|
|
||||||
**Testing**: Pest unit + feature tests only
|
|
||||||
**Validation Lanes**: fast-feedback, confidence
|
|
||||||
**Target Platform**: Sail-backed Laravel admin panel under `/admin` and existing support-diagnostic previews in tenant and operation contexts
|
|
||||||
**Project Type**: web
|
|
||||||
**Performance Goals**: in-memory topic lookup only, no new remote calls during render, and no extra persistence or background work for the first slice
|
|
||||||
**Constraints**: no new database table, no public docs site, no chatbot, no localization overhaul, no new global-search resource, no panel provider changes, no new Filament assets, and no direct feature-level AI execution
|
|
||||||
**Scale/Scope**: 8 canonical first-slice help topics across onboarding and support diagnostics, 1 code-owned catalog, 1 resolver, 1 machine-readable knowledge source, and focused adoption on 2 existing operator surface families
|
|
||||||
|
|
||||||
## First-Slice Topic Inventory
|
|
||||||
|
|
||||||
The implementation is locked to these eight canonical topic keys for the first slice:
|
|
||||||
|
|
||||||
- `admin-consent-required`
|
|
||||||
- `required-permissions-missing`
|
|
||||||
- `connection-unhealthy`
|
|
||||||
- `verification-stale`
|
|
||||||
- `verification-failed`
|
|
||||||
- `diagnostic-evidence-incomplete`
|
|
||||||
- `retryable-provider-failure`
|
|
||||||
- `manual-handoff-required`
|
|
||||||
|
|
||||||
Any change to this topic inventory requires an explicit spec update before implementation expands or swaps the slice.
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Plan
|
|
||||||
|
|
||||||
- **Guardrail scope**: changed surfaces
|
|
||||||
- **Native vs custom classification summary**: native Filament + shared diagnostics data
|
|
||||||
- **Shared-family relevance**: status messaging, docs links, troubleshooting guidance, support-diagnostic summaries
|
|
||||||
- **State layers in scope**: page, workflow step, detail reveal, action preview, diagnostic section detail
|
|
||||||
- **Handling modes by drift class or surface**: review-mandatory
|
|
||||||
- **Repository-signal treatment**: review-mandatory
|
|
||||||
- **Special surface test profiles**: standard-native-filament, monitoring-state-page
|
|
||||||
- **Required tests or manual smoke**: functional-core, state-contract
|
|
||||||
- **Exception path and spread control**: none
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
|
|
||||||
## Shared Pattern & System Fit
|
|
||||||
|
|
||||||
- **Cross-cutting feature marker**: yes
|
|
||||||
- **Systems touched**: `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder`, `App\Support\Governance\PlatformVocabularyGlossary`, `App\Support\ReasonTranslation\ReasonPresenter`, `App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder`, `App\Support\Providers\ProviderReasonTranslator`, and `App\Support\Links\RequiredPermissionsLinks`
|
|
||||||
- **Shared abstractions reused**: glossary classification, reason envelopes, operator-explanation patterns, support-diagnostic section assembly, and existing provider docs-link helpers
|
|
||||||
- **New abstraction introduced? why?**: one bounded contextual-help catalog plus one resolver are justified because the repo has truthful status and glossary primitives already, but it has no shared product-knowledge layer with stable topic keys, troubleshooting guidance, or machine-readable knowledge source
|
|
||||||
- **Why the existing abstraction was sufficient or insufficient**: existing abstractions explain current state, but not reusable contextual help. They are sufficient as inputs and insufficient as the final cross-surface help contract.
|
|
||||||
- **Bounded deviation / spread control**: no page-local help registries, no second glossary, and no product-knowledge persistence. Provider-specific remediation remains bounded to provider-owned topic entries and existing link helpers only.
|
|
||||||
|
|
||||||
## OperationRun UX Impact
|
|
||||||
|
|
||||||
- **Touches OperationRun start/completion/link UX?**: no
|
|
||||||
- **Central contract reused**: N/A
|
|
||||||
- **Delegated UX behaviors**: N/A
|
|
||||||
- **Surface-owned behavior kept local**: N/A
|
|
||||||
- **Queued DB-notification policy**: N/A
|
|
||||||
- **Terminal notification path**: N/A
|
|
||||||
- **Exception path**: none
|
|
||||||
|
|
||||||
## Provider Boundary & Portability Fit
|
|
||||||
|
|
||||||
- **Shared provider/platform boundary touched?**: yes
|
|
||||||
- **Provider-owned seams**: `RequiredPermissionsLinks`, provider-specific consent/permission guidance, and `ProviderReasonTranslator`-backed help topics
|
|
||||||
- **Platform-core seams**: contextual-help topic IDs, glossary mapping, onboarding help rendering, support-diagnostic help rendering, and the machine-readable catalog export
|
|
||||||
- **Neutral platform terms / contracts preserved**: contextual help, help topic, troubleshooting guidance, next action, support diagnostics, readiness guidance
|
|
||||||
- **Retained provider-specific semantics and why**: Microsoft admin consent and required-permissions steps remain provider-specific because those remediation paths are concrete current-release truth rather than speculative portability work
|
|
||||||
- **Bounded extraction or follow-up path**: `document-in-feature` for future localization compatibility; no broader follow-up is required for the first slice
|
|
||||||
|
|
||||||
## Constitution Check
|
|
||||||
|
|
||||||
*GATE: Must pass before implementation begins. Re-check after design changes.*
|
|
||||||
|
|
||||||
- Inventory-first / snapshots-second: PASS - contextual help is derived from existing truth only and does not become a new system-of-record
|
|
||||||
- Read/write separation: PASS - the slice is read-only guidance only and introduces no new mutation flow
|
|
||||||
- Graph contract path: PASS - the feature adds no new Microsoft Graph calls
|
|
||||||
- RBAC-UX / workspace isolation / tenant isolation: PASS - existing onboarding, tenant, and operation-view entitlements stay authoritative and contextual help resolves only after host-surface authorization succeeds
|
|
||||||
- Shared pattern reuse / `XCUT-001`: PASS - the design explicitly extends glossary, reason, operator-explanation, support-diagnostic, and existing link helpers instead of adding local help prose paths
|
|
||||||
- Proportionality / `PROP-001` and `ABSTR-001`: PASS - one bounded catalog and one resolver are the narrowest reusable shape that avoids page-local drift
|
|
||||||
- Persisted truth / `PERSIST-001`: PASS - no new persistence is introduced
|
|
||||||
- UI semantics / `UI-SEM-001`: PASS - the feature adds progressive disclosure help only and does not replace the host surface's truth model
|
|
||||||
- Filament-native UI / `UI-FIL-001`: PASS - onboarding and preview hosts remain native Filament/shared surfaces; no bespoke status cards or asset changes are planned
|
|
||||||
- Livewire v4 / Filament v5: PASS - the feature remains fully within the existing Filament v5 + Livewire v4 stack and requires no provider registration changes beyond the current `bootstrap/providers.php` location
|
|
||||||
- Global search rule: N/A - no new resource or global-search configuration is introduced
|
|
||||||
- Destructive actions: PASS - no new destructive action is introduced; existing confirmations remain unchanged
|
|
||||||
- Asset strategy: PASS - no new global or on-demand assets are planned, so `filament:assets` deployment behavior is unchanged
|
|
||||||
- Test governance / `TEST-GOV-001`: PASS - proof remains in focused unit + feature tests only
|
|
||||||
|
|
||||||
## Test Governance Check
|
|
||||||
|
|
||||||
- **Test purpose / classification by changed surface**: Unit for catalog shape, resolver behavior, and fallback/export safety; Feature for onboarding help rendering, support-diagnostic help rendering, and authorization-safe degradation
|
|
||||||
- **Affected validation lanes**: fast-feedback, confidence
|
|
||||||
- **Why this lane mix is the narrowest sufficient proof**: the feature is server-driven and view-model oriented; browser automation would duplicate what focused unit and feature tests can already prove
|
|
||||||
- **Narrowest proving command(s)**:
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php`
|
|
||||||
- **Fixture / helper / factory / seed / context cost risks**: reuse existing workspace, onboarding draft, tenant, provider connection, operation run, and support-diagnostic fixtures; avoid new browser or provider-emulator defaults
|
|
||||||
- **Expensive defaults or shared helper growth introduced?**: no
|
|
||||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
|
||||||
- **Surface-class relief / special coverage rule**: standard-native-filament relief for onboarding plus one monitoring-state-page regression for the operation-context support-diagnostic host
|
|
||||||
- **Closing validation and reviewer handoff**: reviewers should verify registry-backed help only, progressive disclosure, glossary alignment, authorization-safe link resolution, graceful fallback on missing topics, and zero panel/provider/asset/global-search drift
|
|
||||||
- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep
|
|
||||||
- **Review-stop questions**: did the implementation add page-local help prose, new persistence, or new AI execution; do missing topics fail gracefully; do help links stay entitlement-safe?
|
|
||||||
- **Escalation path**: `reject-or-split` if the implementation grows into a public docs platform, localization rewrite, or AI execution feature; otherwise changes stay inside this feature
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Why no dedicated follow-up spec is needed**: the first slice is intentionally narrow and can land independently before broader localization, support, or AI work
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Documentation (this feature)
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/244-product-knowledge-contextual-help/
|
|
||||||
├── checklists/
|
|
||||||
│ └── requirements.md
|
|
||||||
├── spec.md
|
|
||||||
├── plan.md
|
|
||||||
└── tasks.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source Code (repository root)
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/platform/
|
|
||||||
├── app/
|
|
||||||
│ ├── Filament/
|
|
||||||
│ │ ├── Pages/
|
|
||||||
│ │ │ ├── Operations/TenantlessOperationRunViewer.php
|
|
||||||
│ │ │ ├── TenantDashboard.php
|
|
||||||
│ │ │ └── Workspaces/ManagedTenantOnboardingWizard.php
|
|
||||||
│ │ └── Support/
|
|
||||||
│ ├── Support/
|
|
||||||
│ │ ├── Governance/PlatformVocabularyGlossary.php
|
|
||||||
│ │ ├── Links/RequiredPermissionsLinks.php
|
|
||||||
│ │ ├── ProductKnowledge/
|
|
||||||
│ │ │ ├── ContextualHelpCatalog.php
|
|
||||||
│ │ │ └── ContextualHelpResolver.php
|
|
||||||
│ │ ├── ReasonTranslation/ReasonPresenter.php
|
|
||||||
│ │ ├── SupportDiagnostics/SupportDiagnosticBundleBuilder.php
|
|
||||||
│ │ └── Ui/OperatorExplanation/OperatorExplanationBuilder.php
|
|
||||||
│ └── Services/
|
|
||||||
└── tests/
|
|
||||||
├── Unit/Support/ProductKnowledge/
|
|
||||||
│ ├── ContextualHelpCatalogTest.php
|
|
||||||
│ ├── ContextualHelpResolverTest.php
|
|
||||||
│ └── ContextualHelpFallbackTest.php
|
|
||||||
└── Feature/
|
|
||||||
├── Onboarding/ProductKnowledgeOnboardingHelpTest.php
|
|
||||||
└── SupportDiagnostics/
|
|
||||||
├── ProductKnowledgeAuthorizationTest.php
|
|
||||||
└── ProductKnowledgeSupportDiagnosticHelpTest.php
|
|
||||||
```
|
|
||||||
|
|
||||||
**Structure Decision**: Single Laravel web application. The implementation adds one small support namespace and adopts it on existing onboarding and support-diagnostic surfaces only.
|
|
||||||
|
|
||||||
## Complexity Tracking
|
|
||||||
|
|
||||||
No constitution violations are required. The only new structure is the explicitly justified code-owned contextual-help catalog plus resolver.
|
|
||||||
|
|
||||||
## Proportionality Review
|
|
||||||
|
|
||||||
- **Current operator problem**: operators still need founder explanation or scattered docs to interpret onboarding blockers and support-diagnostic dominant issues safely
|
|
||||||
- **Existing structure is insufficient because**: the repo has truthful glossary, reason, and diagnostic primitives but no versioned, reusable product-knowledge layer
|
|
||||||
- **Narrowest correct implementation**: one code-owned catalog plus one resolver reused by onboarding and support diagnostics only
|
|
||||||
- **Ownership cost created**: topic keys, docs-link mappings, fallback behavior, and focused tests
|
|
||||||
- **Alternative intentionally rejected**: page-local prose, public docs platform, CMS/editor, or AI execution layer
|
|
||||||
- **Release truth**: current-release truth
|
|
||||||
|
|
||||||
## Rollout & Risk Controls
|
|
||||||
|
|
||||||
- Start with onboarding guidance and support diagnostics only. Any third adoption surface requires explicit scope review.
|
|
||||||
- Keep help blocks progressive and subordinate to the host surface's existing status or diagnostic truth.
|
|
||||||
- Use only approved docs-link helpers or stable URLs for the first slice. No free-text or user-authored help content is allowed.
|
|
||||||
- Keep the machine-readable knowledge source internal and code-owned. No runtime AI invocation or customer-facing knowledge export is part of this slice.
|
|
||||||
|
|
||||||
## Implementation Outline
|
|
||||||
|
|
||||||
- Add `App\Support\ProductKnowledge\ContextualHelpCatalog` and `ContextualHelpResolver` as the single shared path for first-slice help topics.
|
|
||||||
- Integrate onboarding help topic selection inside `ManagedTenantOnboardingWizard` using the existing readiness, permission, and verification signals already present on the page.
|
|
||||||
- Integrate contextual help into `SupportDiagnosticBundleBuilder` so tenant and operation-context previews render the same help payload from the same topic keys.
|
|
||||||
- Expose a safe machine-readable knowledge-source method from the catalog or resolver and add focused unit + feature coverage for rendering, authorization, and fallback.
|
|
||||||
|
|
||||||
## Constitution Check (Post-Design)
|
|
||||||
|
|
||||||
Re-check result: PASS. The plan stays bounded to one code-owned catalog and one resolver, reuses existing glossary/reason/support primitives, adds no new persistence, keeps Filament v5 / Livewire v4 unchanged, leaves provider registration in `bootstrap/providers.php` untouched, introduces no global-search or asset changes, and keeps proof in narrow unit + feature coverage only.
|
|
||||||
|
|
||||||
@ -1,283 +0,0 @@
|
|||||||
# Feature Specification: Product Knowledge & Contextual Help
|
|
||||||
|
|
||||||
**Feature Branch**: `244-product-knowledge-contextual-help`
|
|
||||||
**Created**: 2026-04-26
|
|
||||||
**Status**: Ready for implementation
|
|
||||||
**Input**: User description: "Promote the roadmap-fit candidate Product Knowledge & Contextual Help as a narrow, implementation-ready slice that introduces a code-owned contextual help contract for operator-facing guidance on existing onboarding and diagnostics surfaces, reuses glossary and reason-translation foundations, and stops before AI, chatbot, or public docs platform scope."
|
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
|
||||||
|
|
||||||
- **Problem**: Operators still need founder explanation when onboarding blockers, support-diagnostic summaries, or reason-translated states explain what happened but not the safest next step, the relevant documentation, or the surrounding product meaning.
|
|
||||||
- **Today's failure**: Tenant onboarding and support-oriented diagnostic surfaces already expose truthful status and reason signals, but help remains scattered across local copy, existing docs knowledge, or founder memory. That slows onboarding, increases support load, and leaves later AI-assisted support without a trusted product-knowledge source.
|
|
||||||
- **User-visible improvement**: Operators see contextual help on two high-value existing surfaces with canonical terminology, troubleshooting hints, and documentation links that match the current issue without replacing the underlying truth or opening raw diagnostics first.
|
|
||||||
- **Smallest enterprise-capable version**: Add one code-owned contextual help catalog plus one resolver that reuses the existing glossary, reason-translation, operator-explanation, and required-permissions link helpers for two adoption surfaces only: the managed-tenant onboarding workflow and the support-diagnostic bundle in tenant and operation contexts.
|
|
||||||
- **Explicit non-goals**: No public docs site, no AI chatbot, no broad CMS/editor workflow, no complete localization overhaul, no customer-facing help center, no rewrite of every operator surface, and no new persisted product-knowledge table.
|
|
||||||
- **Permanent complexity imported**: One bounded `ProductKnowledge` support namespace, one catalog of stable help topic keys, one resolver/presenter path, one machine-readable source export for later AI/support use, and focused unit plus feature tests.
|
|
||||||
- **Why now**: The repo already has `PlatformVocabularyGlossary`, `ReasonPresenter`, `OperatorExplanationBuilder`, `ManagedTenantOnboardingWizard`, and `SupportDiagnosticBundleBuilder`. Product Knowledge is the smallest next slice that makes onboarding and support less founder-dependent while preparing a safe knowledge source for later AI-assisted support.
|
|
||||||
- **Why not local**: Local page copy would duplicate glossary and reason semantics, drift across onboarding and diagnostics surfaces, and fail to produce one reviewable, versioned, machine-readable product-knowledge source.
|
|
||||||
- **Approval class**: Workflow Compression
|
|
||||||
- **Red flags triggered**: New meta-infrastructure and a foundation-sounding theme. Defense: the slice stays bounded to two existing adoption surfaces, introduces no new persistence, and reuses existing glossary/reason/support primitives rather than inventing a generic knowledge platform.
|
|
||||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
|
||||||
- **Decision**: approve
|
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
|
||||||
|
|
||||||
- **Scope**: workspace
|
|
||||||
- **Primary Routes**:
|
|
||||||
- `/admin/onboarding`
|
|
||||||
- `/admin/onboarding/{onboardingDraft}`
|
|
||||||
- existing tenant support-diagnostics entry points on `/admin/t/{tenant}`
|
|
||||||
- existing canonical operation detail support-diagnostics entry points on `/admin/operations/{run}`
|
|
||||||
- **Data Ownership**:
|
|
||||||
- No new database table or persisted product-knowledge entity is introduced.
|
|
||||||
- The contextual help catalog remains code-owned, reviewable, and versioned in the repository.
|
|
||||||
- Source truth remains on `PlatformVocabularyGlossary`, `ReasonResolutionEnvelope`, `OperatorExplanationPattern`, `SupportDiagnosticBundleBuilder`, and existing route/link helpers.
|
|
||||||
- Any machine-readable knowledge source exported by the feature is derived from the code-owned catalog and MUST exclude customer content, provider payloads, and secrets.
|
|
||||||
- **RBAC**:
|
|
||||||
- This slice introduces no new capability family.
|
|
||||||
- Existing onboarding authorization remains authoritative for `/admin/onboarding` and the managed-tenant onboarding draft flow.
|
|
||||||
- Existing support-diagnostics and operation-view entitlement checks remain authoritative for tenant and operation diagnostic entry points.
|
|
||||||
- Non-members or wrong-scope actors continue to receive 404. In-scope actors lacking the existing capability continue to receive 403. Help resolution never runs before those scope checks pass.
|
|
||||||
|
|
||||||
For canonical-view specs, the spec MUST define:
|
|
||||||
|
|
||||||
- **Default filter behavior when tenant-context is active**: N/A - this slice does not add a new canonical list or queue. It annotates existing onboarding and diagnostic detail surfaces only.
|
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Contextual help is resolved only after the host surface has already resolved workspace and tenant entitlement. Help topics may reference only routes, documents, and next steps the current actor is already entitled to see.
|
|
||||||
|
|
||||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
|
||||||
|
|
||||||
- **Cross-cutting feature?**: yes
|
|
||||||
- **Interaction class(es)**: status messaging, supporting docs links, troubleshooting guidance, support-diagnostic summaries, onboarding next-step guidance
|
|
||||||
- **Systems touched**: `ManagedTenantOnboardingWizard`, `SupportDiagnosticBundleBuilder`, `PlatformVocabularyGlossary`, `ReasonPresenter`, `OperatorExplanationBuilder`, `ProviderReasonTranslator`, and `RequiredPermissionsLinks`
|
|
||||||
- **Existing pattern(s) to extend**: canonical glossary terms, reason-translation envelopes, operator-explanation summaries, support-diagnostic section assembly, and existing required-permissions/admin-consent link helpers
|
|
||||||
- **Shared contract / presenter / builder / renderer to reuse**: `App\Support\Governance\PlatformVocabularyGlossary`, `App\Support\ReasonTranslation\ReasonPresenter`, `App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder`, `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder`, and `App\Support\Links\RequiredPermissionsLinks`
|
|
||||||
- **Why the existing shared path is sufficient or insufficient**: existing shared paths already provide truthful labels, diagnostic summaries, glossary boundaries, and remediation links, but they do not provide one reviewable cross-surface product-knowledge layer with stable help topic keys, progressive disclosure copy, or a machine-readable knowledge source.
|
|
||||||
- **Allowed deviation and why**: provider-specific consent and required-permissions guidance may remain inside provider-owned help topics because the concrete remediation path is still Microsoft-specific in the current release.
|
|
||||||
- **Consistency impact**: topic keys, help headings, glossary nouns, troubleshooting steps, and docs links must stay aligned across onboarding and support diagnostics so the same state does not produce competing explanations.
|
|
||||||
- **Review focus**: reviewers must block page-local contextual-help prose that bypasses the shared catalog and must confirm that help copy stays derived from glossary/reason/support truth rather than becoming a second semantic source of truth.
|
|
||||||
|
|
||||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
|
||||||
|
|
||||||
- **Touches OperationRun start/completion/link UX?**: no
|
|
||||||
- **Shared OperationRun UX contract/layer reused**: N/A - the slice annotates existing onboarding and support-diagnostic surfaces only and does not change how runs are started, linked, or messaged.
|
|
||||||
- **Delegated start/completion UX behaviors**: N/A
|
|
||||||
- **Local surface-owned behavior that remains**: N/A
|
|
||||||
- **Queued DB-notification policy**: N/A
|
|
||||||
- **Terminal notification path**: N/A
|
|
||||||
- **Exception required?**: none
|
|
||||||
|
|
||||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
|
||||||
|
|
||||||
- **Shared provider/platform boundary touched?**: yes
|
|
||||||
- **Boundary classification**: mixed
|
|
||||||
- **Seams affected**: provider permission/consent guidance, provider reason translation reuse, glossary-backed terminology, support-diagnostic guidance, and documentation link resolution
|
|
||||||
- **Neutral platform terms preserved or introduced**: contextual help, help topic, troubleshooting guidance, next action, support diagnostics, readiness, operator guidance
|
|
||||||
- **Provider-specific semantics retained and why**: Microsoft admin consent and required-permissions guidance remain provider-owned because those remediation steps still require exact provider terminology and URLs in the current release.
|
|
||||||
- **Why this does not deepen provider coupling accidentally**: provider-specific help remains attached to provider-owned topics and existing provider link helpers. The top-level catalog, topic IDs, glossary references, and host-surface contracts remain platform-neutral.
|
|
||||||
- **Follow-up path**: `document-in-feature` for the Platform Localization v1 dependency boundary; no follow-up spec is required for the bounded first slice itself.
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
|
||||||
|
|
||||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| Managed tenant onboarding workflow | yes | Native Filament + shared primitives | status messaging, docs links, readiness guidance | page, workflow step, detail reveal | no | Adds contextual help beside existing readiness and verification signals only |
|
|
||||||
| Tenant dashboard support-diagnostic preview | yes | Native Filament action + shared diagnostics bundle | diagnostic summaries, docs links, troubleshooting guidance | action preview, diagnostic section detail | no | Help enriches the existing derived bundle instead of creating a second support surface |
|
|
||||||
| Operation detail support-diagnostic preview | yes | Native Filament detail action + shared diagnostics bundle | diagnostic summaries, docs links, troubleshooting guidance | detail, action preview, diagnostic section detail | no | Reuses the same help-resolution path as tenant diagnostics with operation-context inputs |
|
|
||||||
|
|
||||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| Managed tenant onboarding workflow | Primary Decision Surface | Decide what blocker to resolve next so onboarding can continue safely | current blocker meaning, one help headline, one safe next step, and one supporting docs link when relevant | full verification detail, provider-specific evidence, operation detail, and raw diagnostics | Primary because the operator is already in the guided setup workflow and needs help in that context | Follows the identify-connect-verify-complete onboarding workflow | Removes the need to switch to founder memory or separate documentation to interpret the blocker |
|
|
||||||
| Tenant dashboard support-diagnostic preview | Secondary Context Surface | Decide how to troubleshoot or escalate a tenant issue from one support-safe summary | dominant issue meaning, contextual help headline, troubleshooting hints, and safe next step | full bundle sections, related records, and diagnostic evidence | Secondary because support diagnostics remain a follow-up to tenant work, not the primary workflow | Follows tenant troubleshooting and escalation flow | Reduces cross-page reconstruction and repeated explanation work |
|
|
||||||
| Operation detail support-diagnostic preview | Tertiary Evidence / Diagnostics Surface | Decide what the current run outcome means before drilling deeper or escalating | run summary meaning, contextual help headline, troubleshooting hints, and safe next step | canonical run detail, related records, and provider diagnostics | Tertiary because the surface is already evidence-first and the help layer should remain progressive disclosure | Follows monitoring and support drill-in flow | Makes the existing diagnostic surface more self-explanatory without turning it into a new queue |
|
|
||||||
|
|
||||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Managed tenant onboarding workflow | Workflow / Guided action entry | Guided onboarding / readiness workflow | Resolve the blocker or continue to the next checkpoint | In-page readiness and contextual-help section on the current draft route | forbidden | Supporting docs and diagnostics stay inside the section reveal | Existing destructive draft actions remain in the header only | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Workspace context, linked tenant, provider readiness summary, help topic scope | Onboarding / Onboarding guidance | Blocker meaning, safe next step, and supporting docs link where applicable | guided-workflow exception already inherent to the onboarding wizard |
|
|
||||||
| Tenant dashboard support-diagnostic preview | Dashboard / Overview / Actions | Tenant troubleshooting support entry point | Open support diagnostics and follow the documented next troubleshooting step | Explicit support-diagnostics action opens the read-only preview | forbidden | Related record links and docs links remain inside the preview | none | `/admin/t/{tenant}` | `/admin/t/{tenant}` | Active workspace, active tenant, help topic scope, bundle freshness | Support diagnostics / Diagnostic help | Dominant issue meaning, troubleshooting guidance, and supporting docs links | dashboard-action entry point only; help remains read-only |
|
|
||||||
| Operation detail support-diagnostic preview | Record / Detail / Actions | Canonical diagnostic detail support entry point | Open support diagnostics and follow the documented next troubleshooting step | Existing operation detail plus one explicit support-diagnostics action | forbidden | Related record links and docs links remain inside the preview | none | `/admin/operations` | `/admin/operations/{run}` | Workspace context, tenant context when present, help topic scope, bundle freshness | Support diagnostics / Diagnostic help | Dominant issue meaning, troubleshooting guidance, and supporting docs links | none |
|
|
||||||
|
|
||||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Managed tenant onboarding workflow | Workspace operator managing tenant setup | Decide what the current blocker means and what action to take next | Guided workflow | What does this blocker mean, and what should I do next? | readiness headline, blocker explanation, one safe next action, one docs link where relevant, and current checkpoint context | provider-specific evidence, full verification report, operation detail, low-level identifiers | readiness, data freshness, provider health, operator actionability | Existing onboarding actions keep their current scope; the help layer itself is read-only | Continue onboarding, open docs link, open supporting diagnostics | Existing cancel/delete draft actions remain unchanged |
|
|
||||||
| Tenant dashboard support-diagnostic preview | Support-capable tenant operator or manager | Decide whether to troubleshoot, hand off, or escalate a tenant issue | Read-only preview | What does the current tenant issue mean, and which documented next step is safest? | dominant issue explanation, contextual help headline, troubleshooting steps, and docs links | full support bundle sections, related records, provider diagnostics, audit references | execution outcome, provider health, findings pressure, guidance actionability | none | Open support diagnostics, open docs link, open related records | none |
|
|
||||||
| Operation detail support-diagnostic preview | Support-capable operator | Decide whether to troubleshoot, hand off, or escalate a run-centered issue | Read-only preview | What does this run outcome mean, and which documented next step applies? | dominant issue explanation, contextual help headline, troubleshooting steps, and docs links | full support bundle sections, run detail, provider diagnostics, audit references | execution outcome, trustworthiness, guidance actionability | none | Open support diagnostics, open docs link, open related records | none |
|
|
||||||
|
|
||||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
|
||||||
|
|
||||||
- **New source of truth?**: no
|
|
||||||
- **New persisted entity/table/artifact?**: no
|
|
||||||
- **New abstraction?**: yes
|
|
||||||
- **New enum/state/reason family?**: no
|
|
||||||
- **New cross-domain UI framework/taxonomy?**: yes, one bounded contextual-help topic catalog for the first slice
|
|
||||||
- **Current operator problem**: operators still need founder explanation or external notes to interpret onboarding blockers and support-diagnostic dominant issues safely.
|
|
||||||
- **Existing structure is insufficient because**: glossary and reason translation explain current state, but they do not provide one reusable, versioned, cross-surface product-knowledge layer with troubleshooting hints, docs links, or a machine-readable knowledge source.
|
|
||||||
- **Narrowest correct implementation**: one code-owned contextual-help catalog plus one resolver for onboarding and support diagnostics only, reusing existing glossary/reason/support primitives and avoiding persistence, CMS tooling, or AI execution.
|
|
||||||
- **Ownership cost**: maintain help topic keys, docs-link mappings, glossary alignment, fallback handling, and focused unit plus feature tests.
|
|
||||||
- **Alternative intentionally rejected**: page-local help copy was rejected as drift-prone, and a full public-docs/help-center platform was rejected as broader than current-release truth.
|
|
||||||
- **Release truth**: current-release truth
|
|
||||||
|
|
||||||
### Compatibility posture
|
|
||||||
|
|
||||||
This feature assumes a pre-production environment.
|
|
||||||
|
|
||||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
|
||||||
|
|
||||||
Canonical replacement is preferred over preservation.
|
|
||||||
|
|
||||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
|
||||||
|
|
||||||
- **Test purpose / classification**: Unit, Feature
|
|
||||||
- **Validation lane(s)**: fast-feedback, confidence
|
|
||||||
- **Why this classification and these lanes are sufficient**: unit tests prove the bounded catalog, topic resolution, glossary linkage, and fallback behavior. Feature tests prove the two adopted surfaces render contextual help only after existing authorization succeeds and do so without introducing new routes, persistence, or browser-only behavior.
|
|
||||||
- **New or expanded test families**: one focused `ProductKnowledge` unit family and targeted feature coverage for onboarding help rendering, support-diagnostic help rendering, and authorization-safe fallback behavior.
|
|
||||||
- **Fixture / helper cost impact**: low-to-moderate. Reuse existing onboarding draft, tenant, workspace, provider connection, operation run, and support-diagnostic fixtures. No new browser harness, provider emulator, or heavy-governance lane is required.
|
|
||||||
- **Heavy-family visibility / justification**: none
|
|
||||||
- **Special surface test profile**: standard-native-filament, monitoring-state-page
|
|
||||||
- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient for the onboarding wizard; the operation detail adoption also needs one monitoring-state-page regression because the contextual help is rendered from the support-diagnostic path on a monitoring-oriented surface.
|
|
||||||
- **Reviewer handoff**: reviewers must confirm that contextual help remains registry-backed, progressive, and entitlement-safe; that missing help topics fail predictably; and that help copy does not become a second source of truth or change operation/onboarding authorization semantics.
|
|
||||||
- **Budget / baseline / trend impact**: low increase in narrow unit and feature coverage only
|
|
||||||
- **Escalation needed**: none
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Planned validation commands**:
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php`
|
|
||||||
|
|
||||||
## First-Slice Topic Inventory *(mandatory for implementation lock-in)*
|
|
||||||
|
|
||||||
The first slice is locked to the following eight canonical help topic keys. Adding or replacing a first-slice topic requires an explicit spec update.
|
|
||||||
|
|
||||||
| Topic Key | Intended Surface Families | Primary Trigger | Shared Truth Reused |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `admin-consent-required` | onboarding, support diagnostics | provider readiness or dominant issue indicates admin consent is still required | `RequiredPermissionsLinks`, glossary terms, reason translation |
|
|
||||||
| `required-permissions-missing` | onboarding, support diagnostics | provider readiness or dominant issue indicates required permissions are missing or incomplete | `RequiredPermissionsLinks`, glossary terms, reason translation |
|
|
||||||
| `connection-unhealthy` | onboarding, support diagnostics | provider connection health is degraded or disconnected | operator explanation, reason translation, diagnostic summary |
|
|
||||||
| `verification-stale` | onboarding | verification has not been refreshed recently enough to trust readiness | onboarding verification state, glossary terms |
|
|
||||||
| `verification-failed` | onboarding, support diagnostics | verification or readiness checks completed with a failing result | operator explanation, reason translation |
|
|
||||||
| `diagnostic-evidence-incomplete` | support diagnostics | the bundle cannot prove a dominant issue with high confidence because evidence is incomplete | diagnostic bundle summary, glossary terms |
|
|
||||||
| `retryable-provider-failure` | support diagnostics | support diagnostics indicate a provider-side failure that is safe to retry or re-check | reason translation, operator explanation |
|
|
||||||
| `manual-handoff-required` | support diagnostics | the system can summarize the problem but requires a human support handoff or explicit escalation path | diagnostic bundle summary, glossary terms, approved docs links |
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - Explain Onboarding Blockers In Context (Priority: P1)
|
|
||||||
|
|
||||||
As a workspace operator, I want onboarding blockers to show contextual help with canonical terminology, safe next steps, and supporting docs links so I can continue onboarding without founder intervention.
|
|
||||||
|
|
||||||
**Why this priority**: This is the most immediate operator-facing support reduction in the roadmap cluster and reuses the repo's existing onboarding and permission diagnostics foundations.
|
|
||||||
|
|
||||||
**Independent Test**: Open onboarding drafts that are blocked by missing consent, missing permissions, unhealthy provider connection, or stale verification and verify that the wizard shows registry-backed contextual help without changing the existing readiness truth.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an authorized operator opens an onboarding draft blocked by missing admin consent, **When** the readiness step renders, **Then** the workflow shows a contextual help headline, one safe next step, and an admin-consent docs/action link derived from the shared help registry.
|
|
||||||
2. **Given** an authorized operator opens an onboarding draft blocked by missing permissions or stale verification, **When** the readiness step renders, **Then** the workflow shows glossary-aligned contextual help that explains the blocker without replacing the existing verification or provider-truth sections.
|
|
||||||
3. **Given** the current user is not entitled to the onboarding scope, **When** they attempt to access the draft, **Then** the system still returns 404 or 403 according to existing rules and reveals no contextual-help details.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Reuse The Same Product Knowledge In Support Diagnostics (Priority: P1)
|
|
||||||
|
|
||||||
As a support-capable operator, I want tenant and operation support-diagnostic previews to show the same contextual help language and troubleshooting guidance so support cases stop depending on ad-hoc explanation.
|
|
||||||
|
|
||||||
**Why this priority**: The second high-value surface proves the knowledge layer is genuinely reusable and not just onboarding-local prose.
|
|
||||||
|
|
||||||
**Independent Test**: Open tenant-context and operation-context support diagnostics for the same dominant issue and verify that the preview renders the same registry-backed help topic, troubleshooting hints, and supporting docs links.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a tenant support-diagnostic bundle resolves a dominant issue such as missing permissions or an unhealthy connection, **When** the preview renders, **Then** it includes registry-backed contextual help aligned with the dominant issue and leaves the existing diagnostic sections intact.
|
|
||||||
2. **Given** an operation-context support-diagnostic bundle resolves the same dominant issue, **When** the preview renders, **Then** it uses the same help topic key and glossary-aligned language rather than a second local explanation dialect.
|
|
||||||
3. **Given** the dominant issue has no configured help topic in the first slice, **When** the preview renders, **Then** the bundle degrades gracefully without exceptions or raw unresolved keys.
|
|
||||||
4. **Given** a user lacks the existing support-diagnostic entitlement for the tenant or operation scope, **When** they attempt to open the preview, **Then** the host surface preserves the current 404/403 behavior and reveals no contextual-help payload.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Provide A Safe Machine-Readable Knowledge Source (Priority: P2)
|
|
||||||
|
|
||||||
As the product owner, I want the first-slice help catalog to expose a machine-readable knowledge source so later AI-assisted support can reuse trusted product knowledge without scraping UI prose or customer data.
|
|
||||||
|
|
||||||
**Why this priority**: This keeps the first slice aligned with later AI-adjacent work without forcing AI execution or broad platform scope into the current implementation.
|
|
||||||
|
|
||||||
**Independent Test**: Resolve the first-slice catalog into a machine-readable knowledge source and verify that it contains only topic metadata, glossary-aligned text, and allowed docs links, with no tenant-specific data or secrets.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the code-owned help catalog, **When** the machine-readable knowledge source is exported for internal product use, **Then** it contains only stable topic keys, headings, troubleshooting steps, and allowed links.
|
|
||||||
2. **Given** a help topic references existing route or docs helpers, **When** the machine-readable knowledge source is built, **Then** the exported representation contains only safe link metadata and never includes tenant-specific provider payloads, secrets, or free-text customer notes.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- A host surface may resolve a reason or dominant issue that has no mapped help topic in the first slice; the UI must fail predictably and preserve the underlying truth without showing raw topic keys.
|
|
||||||
- A provider-owned topic may have both an internal product route and an external Microsoft docs link; the surfaced links must stay ordered and entitlement-safe.
|
|
||||||
- The same help topic may appear on onboarding and support diagnostics; the wording must remain stable even if the surrounding surface framing differs.
|
|
||||||
- Progressive disclosure must keep the help block subordinate to the surface's primary truth so the product does not imply the help copy is itself the source of truth.
|
|
||||||
- Localization remains out of scope for the first slice; help topics must stay ready for later localization without introducing a second vocabulary layer now.
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
**Constitution alignment (required):** This feature introduces no new Graph call path and no new tenant-changing action. It adds a read-only contextual-help layer on top of existing onboarding and support-diagnostic truths. Existing write, queue, and audit semantics remain unchanged.
|
|
||||||
|
|
||||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The slice introduces one new bounded abstraction because current-release operator workflows now need a reusable help layer. No new persistence, new state family, or generic knowledge platform is introduced.
|
|
||||||
|
|
||||||
**Constitution alignment (XCUT-001):** This slice is cross-cutting across onboarding and support diagnostics. It must reuse the existing glossary, reason-translation, operator-explanation, and support-diagnostic bundle paths rather than introducing page-local help dialects.
|
|
||||||
|
|
||||||
**Constitution alignment (PROV-001):** Provider-specific remediation remains bounded to provider-owned topics and existing docs-link helpers. Platform-core help topics remain provider-neutral.
|
|
||||||
|
|
||||||
**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit and feature lanes only. No browser or heavy-governance family is justified.
|
|
||||||
|
|
||||||
**Constitution alignment (RBAC-UX):** Existing scope and capability checks remain authoritative. Help resolution must not widen access, leak hidden remediation destinations, or replace 404/403 semantics.
|
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX):** The feature does not create or change `OperationRun` start, completion, notification, or link semantics.
|
|
||||||
|
|
||||||
**Constitution alignment (BADGE-001):** The feature introduces no new badge domain. If existing badge or status labels appear inside help, they must be reused from existing catalog-backed semantics.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-FIL-001):** Operator-facing help must use native Filament or shared diagnostic primitives on adopted surfaces. No ad-hoc status cards or new local status language are allowed.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** Help headlines, troubleshooting hints, docs links, and surrounding UI copy must preserve the same canonical vocabulary already used by reason translation, onboarding readiness, and support diagnostics.
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-244-001**: The system MUST define one code-owned contextual-help catalog for the bounded first slice.
|
|
||||||
- **FR-244-002**: The catalog MUST use stable help topic keys and remain reviewable and versioned in the repository.
|
|
||||||
- **FR-244-003**: The contextual-help resolver MUST reuse `PlatformVocabularyGlossary`, reason-translation outputs, operator-explanation outputs, and existing docs-link helpers instead of duplicating those semantics.
|
|
||||||
- **FR-244-004**: The managed-tenant onboarding workflow MUST render registry-backed contextual help for the first-slice blocker families when a matching help topic exists.
|
|
||||||
- **FR-244-005**: Tenant-context and operation-context support-diagnostic previews MUST render registry-backed contextual help for the first-slice dominant-issue families when a matching help topic exists.
|
|
||||||
- **FR-244-006**: Contextual help MUST remain progressive disclosure and MUST NOT replace the host surface's primary truth sections.
|
|
||||||
- **FR-244-007**: Each help topic MUST support a bounded shape containing a headline, short explanation, troubleshooting steps, safe next action, and zero or more supporting docs links.
|
|
||||||
- **FR-244-008**: Help copy MUST reuse canonical glossary and reason-translation vocabulary and MUST NOT invent conflicting synonyms for onboarding, diagnostics, evidence, drift, support, or operation outcomes.
|
|
||||||
- **FR-244-009**: Provider-specific help MUST remain bounded to provider-owned topics and existing provider link helpers.
|
|
||||||
- **FR-244-010**: Missing or invalid help topics MUST degrade gracefully without exceptions, broken UI state, or raw unresolved topic keys.
|
|
||||||
- **FR-244-011**: The feature MUST expose a machine-readable knowledge source safe for future internal AI/support use without tenant-specific data, provider payloads, or secrets.
|
|
||||||
- **FR-244-012**: The first slice MUST NOT introduce a public documentation site, chatbot, CMS/editor, new database table, or customer-facing help center.
|
|
||||||
- **FR-244-013**: Contextual help MUST not change existing onboarding, support-diagnostic, or authorization behavior.
|
|
||||||
- **FR-244-014**: The feature MUST include regression coverage for onboarding help rendering, support-diagnostic help rendering, and missing-topic fallback behavior.
|
|
||||||
- **FR-244-015**: The feature MUST include at least one positive and one negative authorization regression proving that contextual help never leaks hidden scope or destinations.
|
|
||||||
|
|
||||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
|
||||||
|
|
||||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Managed tenant onboarding workflow | `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` | Existing header actions remain unchanged | N/A | none added by this feature | none | existing empty/start state unchanged | N/A | existing onboarding actions unchanged | no | Adds a read-only contextual-help block only; no new destructive or mutating action |
|
|
||||||
| Tenant support-diagnostic preview host | `apps/platform/app/Filament/Pages/TenantDashboard.php` | Existing support-diagnostics action unchanged | N/A | none added by this feature | none | none | N/A | N/A | no | Help is rendered inside the preview content returned by the shared bundle builder |
|
|
||||||
| Operation detail support-diagnostic preview host | `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Existing support-diagnostics action unchanged | N/A | none added by this feature | none | none | existing run actions unchanged | N/A | no | Monitoring/detail action hierarchy remains unchanged; help annotates preview content only |
|
|
||||||
|
|
||||||
### Key Entities *(include if feature involves data)*
|
|
||||||
|
|
||||||
- **Contextual Help Topic**: A code-owned, versioned help entry identified by a stable topic key and containing bounded product guidance only.
|
|
||||||
- **Contextual Help Resolution**: A derived help payload built from catalog entries plus existing glossary, reason, operator-explanation, and docs-link inputs.
|
|
||||||
- **Machine-Readable Knowledge Source**: A safe export of the code-owned help catalog for future internal AI/support consumption.
|
|
||||||
|
|
||||||
## Success Criteria *(mandatory)*
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-244-001**: The first implementation slice renders registry-backed contextual help on at least two critical surfaces: the managed-tenant onboarding workflow and support-diagnostic previews.
|
|
||||||
- **SC-244-002**: In focused regression coverage, 100% of in-scope first-slice blocker and dominant-issue scenarios either render a matching help topic or degrade gracefully without errors or raw unresolved keys.
|
|
||||||
- **SC-244-003**: The machine-readable knowledge source contains only code-owned topic metadata and approved links, with 0 tenant-specific records, raw provider payloads, or secrets in regression coverage.
|
|
||||||
- **SC-244-004**: The adopted surfaces continue to use existing authorization semantics unchanged, with contextual help visible only after the host surface's existing entitlement checks succeed.
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
---
|
|
||||||
|
|
||||||
description: "Task list for Product Knowledge & Contextual Help"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Tasks: Product Knowledge & Contextual Help
|
|
||||||
|
|
||||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/`
|
|
||||||
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/checklists/requirements.md` (required)
|
|
||||||
|
|
||||||
**Tests**: REQUIRED (Pest) for all runtime behavior changes in this slice. Keep proof in Unit + Feature lanes only.
|
|
||||||
**Operations**: This slice must not alter existing `OperationRun` start, completion, notification, or link UX.
|
|
||||||
**RBAC**: Existing onboarding, tenant, and support-diagnostic entitlement checks remain authoritative. No new capability family is introduced.
|
|
||||||
**Organization**: Tasks are grouped by user story so onboarding guidance, support-diagnostic guidance, and the internal machine-readable knowledge source remain independently deliverable.
|
|
||||||
|
|
||||||
## Phase 1: Setup (Shared Infrastructure)
|
|
||||||
|
|
||||||
**Purpose**: Prepare the bounded product-knowledge namespace and the narrow validation surfaces.
|
|
||||||
|
|
||||||
- [x] T001 Create the feature-local support namespace and test directories under `apps/platform/app/Support/ProductKnowledge/`, `apps/platform/tests/Unit/Support/ProductKnowledge/`, `apps/platform/tests/Feature/Onboarding/`, and `apps/platform/tests/Feature/SupportDiagnostics/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Prerequisites)
|
|
||||||
|
|
||||||
**Purpose**: Add the single shared contextual-help catalog and resolver before touching onboarding or support-diagnostic surfaces.
|
|
||||||
|
|
||||||
**Checkpoint**: One bounded product-knowledge path exists before any host surface adopts it.
|
|
||||||
|
|
||||||
- [x] T002 Create the code-owned first-slice topic catalog in `apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php`
|
|
||||||
- [x] T003 Create the shared resolver in `apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php` so help payloads are derived from `PlatformVocabularyGlossary`, `ReasonPresenter`, `OperatorExplanationBuilder`, and `RequiredPermissionsLinks`
|
|
||||||
- [x] T004 Define the minimal machine-readable knowledge-source metadata shape and unresolved-topic fallback contract inside the `ProductKnowledge` namespace so onboarding and support-diagnostic hosts can adopt the shared resolver before US3 hardens the final knowledge-source and fallback guarantees
|
|
||||||
- [x] T005 [P] Add unit coverage for all eight canonical topic keys, resolver behavior, and the foundational fallback/export contract in `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php`, `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php`, and `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php`
|
|
||||||
- [x] T006 Run the foundational unit suite with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: User Story 1 - Explain Onboarding Blockers In Context (Priority: P1) 🎯 MVP
|
|
||||||
|
|
||||||
**Goal**: Show registry-backed contextual help directly in the onboarding workflow so operators can interpret the current blocker without founder explanation.
|
|
||||||
|
|
||||||
**Independent Test**: Open onboarding drafts blocked by consent, permission, connection-health, and verification-freshness issues and verify that the wizard renders the matching help payload without changing the underlying readiness truth.
|
|
||||||
|
|
||||||
### Tests for User Story 1
|
|
||||||
|
|
||||||
- [x] T007 [P] [US1] Add onboarding feature coverage for `admin-consent-required`, `required-permissions-missing`, `connection-unhealthy`, `verification-stale`, `verification-failed`, and positive/negative authorization behavior in `apps/platform/tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 1
|
|
||||||
|
|
||||||
- [x] T008 [US1] Update `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` to derive contextual-help topic inputs from existing readiness, permission, and verification signals and resolve them through the shared `ContextualHelpResolver`
|
|
||||||
- [x] T009 [US1] Render the onboarding help block with native Filament/shared primitives inside `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, keeping the host workflow's existing action hierarchy and destructive actions unchanged
|
|
||||||
- [x] T010 [US1] Run the onboarding slice with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: User Story 2 - Reuse The Same Product Knowledge In Support Diagnostics (Priority: P1)
|
|
||||||
|
|
||||||
**Goal**: Reuse the same contextual-help contract inside tenant and operation-context support-diagnostic previews.
|
|
||||||
|
|
||||||
**Independent Test**: Open tenant and operation support diagnostics for the same dominant issue and verify that both previews render the same topic-backed help payload and degrade safely when a topic is missing.
|
|
||||||
|
|
||||||
### Tests for User Story 2
|
|
||||||
|
|
||||||
- [x] T011 [P] [US2] Add support-diagnostic feature coverage for tenant-context and operation-context rendering of `admin-consent-required`, `required-permissions-missing`, `connection-unhealthy`, `verification-failed`, `diagnostic-evidence-incomplete`, `retryable-provider-failure`, and `manual-handoff-required` in `apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php`
|
|
||||||
- [x] T012 [P] [US2] Add authorization and missing-topic fallback coverage for support-diagnostic help in `apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 2
|
|
||||||
|
|
||||||
- [x] T013 [US2] Update `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` to attach contextual-help payloads derived from dominant issue, provider state, and existing diagnostic summary inputs through the shared resolver
|
|
||||||
- [x] T014 [US2] Update the support-diagnostic preview hosts in `apps/platform/app/Filament/Pages/TenantDashboard.php` and `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` so they render the bundle's contextual-help data without introducing a second host-specific help dialect
|
|
||||||
- [x] T015 [US2] Run the support-diagnostic slice with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: User Story 3 - Provide A Safe Machine-Readable Knowledge Source (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: Harden the scaffolded machine-readable knowledge source and fallback contract so the first-slice catalog stays safe for later internal AI/support reuse without turning the feature into AI execution or a public docs platform.
|
|
||||||
|
|
||||||
**Independent Test**: Export the catalog into its machine-readable knowledge source and verify that it contains only topic metadata and approved links, while missing topics continue to degrade safely.
|
|
||||||
|
|
||||||
### Tests for User Story 3
|
|
||||||
|
|
||||||
- [x] T016 [P] [US3] Extend `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php` and `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php` with finalized machine-readable knowledge-source assertions covering all eight canonical topic keys and approved-link metadata
|
|
||||||
- [x] T017 [P] [US3] Extend `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php` and `apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php` with finalized unresolved-topic, link-safety, and no-raw-key regressions
|
|
||||||
|
|
||||||
### Implementation for User Story 3
|
|
||||||
|
|
||||||
- [x] T018 [US3] Finalize the machine-readable knowledge-source shape in `apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php` so all eight canonical topic keys expose only stable topic metadata, troubleshooting steps, glossary-backed copy, and approved links
|
|
||||||
- [x] T019 [US3] Harden `apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php` so unresolved topics never raise exceptions or leak raw keys into onboarding or support-diagnostic surfaces, building on the foundational contract from T004
|
|
||||||
- [x] T020 [US3] Run the knowledge-source and fallback slice with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Polish & Cross-Cutting Concerns
|
|
||||||
|
|
||||||
**Purpose**: Lock down vocabulary alignment, formatting, and the narrow validation suite before implementation close-out.
|
|
||||||
|
|
||||||
- [x] T021 [P] Confirm that first-slice topic keys, glossary nouns, and approved docs links stay aligned across `apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php`, `apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php`, and the adopted onboarding/support-diagnostic surfaces
|
|
||||||
- [x] T022 Run formatting on touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
|
||||||
- [x] T023 Run the full narrow validation suite with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductKnowledge tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies & Execution Order
|
|
||||||
|
|
||||||
### User Story Dependency Graph
|
|
||||||
|
|
||||||
```text
|
|
||||||
Phase 1 (Setup)
|
|
||||||
↓
|
|
||||||
Phase 2 (Catalog + resolver + fallback/export)
|
|
||||||
↓
|
|
||||||
US1 (onboarding help adoption) ───────────────┐
|
|
||||||
├─→ US3 (safe knowledge-source and fallback hardening)
|
|
||||||
US2 (support-diagnostic help adoption) ───────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Parallel Opportunities
|
|
||||||
|
|
||||||
- The unit tests in Phase 2 can be authored in parallel once the catalog shape is agreed.
|
|
||||||
- Onboarding and support-diagnostic feature tests can be authored in parallel because they touch different host surfaces.
|
|
||||||
- US3 export and fallback hardening can proceed in parallel with late US1/US2 integration cleanup once the shared resolver contract is stable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Governance Checklist
|
|
||||||
|
|
||||||
- [ ] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
|
||||||
- [ ] New or changed tests stay in the smallest honest family, and no heavy-governance or browser family is introduced accidentally.
|
|
||||||
- [ ] Shared helpers and fixture setup remain cheap by default.
|
|
||||||
- [ ] Planned validation commands cover the change without pulling in unrelated lane cost.
|
|
||||||
- [ ] The adopted surfaces explicitly use `standard-native-filament` plus the named monitoring-state-page regression where required.
|
|
||||||
- [ ] No material budget or baseline escalation is introduced.
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
# Specification Quality Checklist: Customer Health Score
|
|
||||||
|
|
||||||
**Purpose**: Validate specification completeness and implementation readiness before the feature moves into the implementation loop
|
|
||||||
**Created**: 2026-04-27
|
|
||||||
**Feature**: [spec.md](../spec.md)
|
|
||||||
|
|
||||||
## Content Quality
|
|
||||||
|
|
||||||
- [x] Business value and operator outcomes stay explicit
|
|
||||||
- [x] The first slice is bounded to derived health summaries on the existing `/system` dashboard
|
|
||||||
- [x] Runtime-governance sections are present for an implementation-ready package
|
|
||||||
- [x] All mandatory sections are completed
|
|
||||||
|
|
||||||
## Requirement Completeness
|
|
||||||
|
|
||||||
- [x] No `[NEEDS CLARIFICATION]` markers remain
|
|
||||||
- [x] Requirements are testable and unambiguous
|
|
||||||
- [x] Acceptance scenarios are defined for the primary user journeys
|
|
||||||
- [x] Edge cases are identified
|
|
||||||
- [x] Scope is clearly bounded away from CRM, billing, predictive scoring, and customer-facing health portals
|
|
||||||
- [x] Dependencies and assumptions are identified
|
|
||||||
|
|
||||||
## Feature Readiness
|
|
||||||
|
|
||||||
- [x] The first slice is small enough for a bounded implementation loop
|
|
||||||
- [x] The plan identifies concrete repo surfaces likely to change
|
|
||||||
- [x] The tasks are ordered, testable, and grouped by user story
|
|
||||||
- [x] Foundational work includes the core unknown-handling and review-pack-readiness rules before dashboard adoption
|
|
||||||
- [x] No unresolved product question blocks safe implementation of the first slice
|
|
||||||
|
|
||||||
## Governance Readiness
|
|
||||||
|
|
||||||
- [x] No new persistence is introduced without justification
|
|
||||||
- [x] Provider-boundary handling and platform-safe deep-link rules are explicit
|
|
||||||
- [x] Existing RBAC and platform-plane access remain authoritative
|
|
||||||
- [x] Operator-facing surface changes include the required UI contract sections
|
|
||||||
- [x] The UI Action Matrix is populated for the modified Filament dashboard surface
|
|
||||||
- [x] The package explicitly requires one platform-safe next link per attention-needed row plus a visible time-basis cue
|
|
||||||
- [x] Default-visible content stays operator-first while raw or support-grade detail remains behind existing linked surfaces
|
|
||||||
- [x] The package avoids duplicate truth by keeping health derived-only and by reusing centralized `SystemHealth` semantics
|
|
||||||
- [x] Livewire v4 compliance, unchanged provider registration location, no global-search changes, no destructive-action additions, and no asset-strategy changes are explicit in the package
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- This checklist completes the implementation-ready package alongside `spec.md`, `plan.md`, and `tasks.md`.
|
|
||||||
- The active slice stays bounded to one derived customer-health support path, two system dashboard widgets, fixed first-slice dimensions, and focused unit plus feature proof only.
|
|
||||||
@ -1,227 +0,0 @@
|
|||||||
# Implementation Plan: Customer Health Score
|
|
||||||
|
|
||||||
**Branch**: `245-customer-health-score` | **Date**: 2026-04-27 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/245-customer-health-score/spec.md`
|
|
||||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/245-customer-health-score/spec.md`
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- Add one bounded `CustomerHealth` support namespace that derives workspace-health summaries from existing onboarding, provider, telemetry, `OperationRun`, findings, and review-pack truth.
|
|
||||||
- Reuse the existing `/system` dashboard, existing dashboard window selector, and existing system widget family to show both aggregate health counts and an attention-needed workspace list.
|
|
||||||
- Reuse the existing system tenant and workspace residual detail pages as decision-first follow-up surfaces by adding one read-only customer-health card above the existing diagnostics.
|
|
||||||
- Keep the slice derived-only, Livewire v4-compatible, Filament v5-native, and free of migrations, new persistence, new panel providers, global-search changes, destructive actions, or asset changes.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
|
||||||
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `ProductTelemetrySummaryQuery`, `ProviderConnectionStateProjector`, `StuckRunClassifier`, `BadgeRenderer`, `SystemConsoleWindow`, system directory links, and system operations links
|
|
||||||
**Storage**: N/A - no new persisted health truth
|
|
||||||
**Testing**: Pest unit + feature tests only
|
|
||||||
**Validation Lanes**: fast-feedback, confidence
|
|
||||||
**Target Platform**: Sail-backed Laravel admin panel under `/system`
|
|
||||||
**Project Type**: web
|
|
||||||
**Performance Goals**: derive health summaries inline for the current system dashboard without background jobs, remote calls, or broad per-tenant page reconstruction
|
|
||||||
**Constraints**: no new score table, no customer-facing surface, no CRM or billing workflow expansion, no direct `/admin` deep links, and no new badge taxonomy
|
|
||||||
**Scale/Scope**: six fixed first-slice dimensions, one derived summary query, two dashboard widgets, one shared decision-first follow-up card on the existing system detail pages, and focused unit plus feature proof only
|
|
||||||
|
|
||||||
## First-Slice Dimension Inventory
|
|
||||||
|
|
||||||
The first slice is locked to these six dimensions only:
|
|
||||||
|
|
||||||
1. **Onboarding readiness** — point-in-time signal derived from existing onboarding-readiness truth
|
|
||||||
2. **Provider connection health** — point-in-time signal derived from existing provider consent and verification truth
|
|
||||||
3. **Operational stability** — windowed signal derived from failed and stuck `OperationRun` truth using the selected `SystemConsoleWindow`
|
|
||||||
4. **Governance pressure** — point-in-time signal derived from active high-severity, overdue, or exception-warning findings truth
|
|
||||||
5. **Review-pack readiness** — narrow mixed signal derived from recent review-pack request context plus the latest relevant `ReviewPack` state
|
|
||||||
6. **Engagement freshness** — windowed signal derived from existing `product_usage_events`
|
|
||||||
|
|
||||||
Any change to this dimension inventory requires an explicit spec update before implementation expands or swaps the slice.
|
|
||||||
|
|
||||||
## Health-Level Resolution Rules
|
|
||||||
|
|
||||||
- Reuse existing `SystemHealth` levels only: `ok`, `warn`, `critical`, `unknown`
|
|
||||||
- Overall level precedence is fixed for v1: `critical` > `warn` > `unknown` > `ok`
|
|
||||||
- Windowed dimensions must honor the selected dashboard time window
|
|
||||||
- Point-in-time dimensions must not pretend to share the same time basis as windowed dimensions
|
|
||||||
- Missing or stale source truth must remain explicit and must never silently collapse to `ok`
|
|
||||||
- Archived workspaces are excluded from active counts, and archived tenants do not contribute source truth into active workspace-health derivation
|
|
||||||
- Attention-needed workspace ordering is fixed for v1: overall severity desc, non-`ok` dimension count desc, workspace name asc, then workspace id asc
|
|
||||||
|
|
||||||
## Review-Pack Readiness Rule
|
|
||||||
|
|
||||||
`Recent` means the currently selected dashboard window. Request context comes from existing review-pack request telemetry when available, falling back to a `ReviewPack` created for the same workspace inside that same window.
|
|
||||||
|
|
||||||
| Condition | Review-pack readiness level | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| No request context and no relevant pack activity in the selected window | `unknown` | The first slice does not infer periodic review obligations from silence |
|
|
||||||
| Request context exists in the selected window and no relevant pack is usable yet | `warn` | Covers queued, running, or not-yet-materialized review-pack generation |
|
|
||||||
| Latest relevant pack in the selected window is ready and not expired | `ok` | A usable recent review pack exists |
|
|
||||||
| Latest relevant pack in the selected window is failed or expired | `critical` | Recent review-pack work ended unusably and needs attention |
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Plan
|
|
||||||
|
|
||||||
- **Guardrail scope**: changed surfaces
|
|
||||||
- **Native vs custom classification summary**: native Filament dashboard plus existing custom system widget family and the existing residual system detail pages
|
|
||||||
- **Shared-family relevance**: dashboard signals/cards, compact attention list, system-safe deep links, existing `SystemHealth` badges
|
|
||||||
- **State layers in scope**: page, widget, window query, detail-link state, residual detail follow-up state
|
|
||||||
- **Handling modes by drift class or surface**: review-mandatory
|
|
||||||
- **Repository-signal treatment**: review-mandatory
|
|
||||||
- **Special surface test profiles**: standard-native-filament
|
|
||||||
- **Required tests or manual smoke**: functional-core, state-contract, follow-up-detail smoke
|
|
||||||
- **Exception path and spread control**: none
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **List-surface review standard**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/docs/product/standards/list-surface-review-checklist.md` applies to `CustomerHealthTopWorkspaces`; accepted compact-widget exceptions are no persistence trio, no bulk actions, no row click, and no empty-state CTA
|
|
||||||
|
|
||||||
## Shared Pattern & System Fit
|
|
||||||
|
|
||||||
- **Cross-cutting feature marker**: yes
|
|
||||||
- **Systems touched**: `App\Filament\System\Pages\Dashboard`, `App\Filament\System\Pages\Directory\ViewTenant`, `App\Filament\System\Pages\Directory\ViewWorkspace`, `App\Filament\System\Widgets\ControlTowerHealthIndicator`, `App\Filament\System\Widgets\ControlTowerTopOffenders`, `App\Support\ProductTelemetry\ProductTelemetrySummaryQuery`, `App\Services\Providers\ProviderConnectionStateProjector`, `App\Support\SystemConsole\StuckRunClassifier`, existing `Finding` truth, existing `ReviewPack` truth, and platform-plane system link helpers
|
|
||||||
- **Shared abstractions reused**: dashboard widget composition, dashboard time-window semantics, `BadgeRenderer` for `SystemHealth`, system-safe link helpers, existing residual system detail pages, and existing source-truth owners for provider, telemetry, operations, findings, and review packs
|
|
||||||
- **New abstraction introduced? why?**: one bounded `CustomerHealth` support namespace with a fixed dimension catalog and one derived summary query is justified because the repo already has isolated source truths but no shared workspace-health derivation path
|
|
||||||
- **Why the existing abstraction was sufficient or insufficient**: existing abstractions are sufficient for rendering and navigation but insufficient for deriving one explainable cross-domain workspace summary
|
|
||||||
- **Bounded deviation / spread control**: no page-local health arithmetic, no second badge language, no persistence, and no customer-success surface. All health derivation must converge on the single `CustomerHealth` namespace.
|
|
||||||
- **Platform-safe link contract**: every attention-needed workspace row must expose exactly one platform-safe next link. If a more specific platform-plane route is not appropriate, the row falls back to the existing system tenant-detail context instead of rendering with no link. Dashboard links into residual detail pages must preserve the selected time window so the follow-up card explains the same top drivers as the dashboard.
|
|
||||||
|
|
||||||
## OperationRun UX Impact
|
|
||||||
|
|
||||||
- **Touches OperationRun start/completion/link UX?**: no
|
|
||||||
- **Central contract reused**: N/A
|
|
||||||
- **Delegated UX behaviors**: N/A
|
|
||||||
- **Surface-owned behavior kept local**: N/A
|
|
||||||
- **Queued DB-notification policy**: N/A
|
|
||||||
- **Terminal notification path**: N/A
|
|
||||||
- **Exception path**: none
|
|
||||||
|
|
||||||
## Provider Boundary & Portability Fit
|
|
||||||
|
|
||||||
- **Shared provider/platform boundary touched?**: yes
|
|
||||||
- **Provider-owned seams**: provider consent and verification outcomes on `ProviderConnection`
|
|
||||||
- **Platform-core seams**: workspace-health dimensions, overall `SystemHealth` level, dominant reason labels, dashboard widget copy, and system-safe next links
|
|
||||||
- **Neutral platform terms / contracts preserved**: customer health, workspace health, attention needed, operational stability, engagement freshness, review readiness
|
|
||||||
- **Retained provider-specific semantics and why**: Microsoft verification and consent states remain provider-owned inputs because they are existing current-release truth already modeled on provider connections
|
|
||||||
- **Bounded extraction or follow-up path**: `document-in-feature` only if one provider-specific dominant reason still needs text-only explanation instead of a platform-safe link in v1
|
|
||||||
|
|
||||||
## Constitution Check
|
|
||||||
|
|
||||||
*GATE: Must pass before implementation begins. Re-check after design changes.*
|
|
||||||
|
|
||||||
- Inventory-first / snapshots-second: PASS - the slice derives health from existing observed truths only
|
|
||||||
- Read/write separation: PASS - the slice is read-only
|
|
||||||
- Graph contract path: PASS - no new Graph calls are added
|
|
||||||
- RBAC-UX / workspace isolation / tenant isolation: PASS - system dashboard access rules remain authoritative and no tenant/admin viewer is introduced
|
|
||||||
- Shared pattern reuse / `XCUT-001`: PASS - dashboard widget family, badge semantics, and system link helpers are reused explicitly
|
|
||||||
- Proportionality / `PROP-001` and `ABSTR-001`: PASS - one bounded derived-query path is the narrowest reusable solution
|
|
||||||
- Persisted truth / `PERSIST-001`: PASS - no new persistence is introduced
|
|
||||||
- UI semantics / `UI-SEM-001`: PASS - the slice adds decision-support summaries only and does not create a new workflow hub
|
|
||||||
- Filament-native UI / `UI-FIL-001`: PASS - the operator-facing impact stays on the existing dashboard widget family
|
|
||||||
- Livewire v4 / Filament v5: PASS - the slice remains fully inside the current Filament v5 + Livewire v4 stack
|
|
||||||
- Provider registration location: PASS - no provider registration changes are introduced; Laravel 11+ provider registration stays in `bootstrap/providers.php`
|
|
||||||
- Global search rule: PASS - no resource or global-search changes are introduced
|
|
||||||
- Destructive actions: PASS - none added
|
|
||||||
- Asset strategy: PASS - no new assets are required, so deployment behavior for `filament:assets` remains unchanged
|
|
||||||
- Test governance / `TEST-GOV-001`: PASS - proof remains in focused unit + feature lanes only
|
|
||||||
|
|
||||||
## Test Governance Check
|
|
||||||
|
|
||||||
- **Test purpose / classification by changed surface**: Unit for dimension rules and summary derivation; Feature for `/system` dashboard rendering, explainability, authorization, and residual detail follow-up rendering
|
|
||||||
- **Affected validation lanes**: fast-feedback, confidence
|
|
||||||
- **Why this lane mix is the narrowest sufficient proof**: the slice is server-driven, read-only, and widget-based; browser automation would duplicate what focused unit and feature tests already prove
|
|
||||||
- **Narrowest proving command(s)**:
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/CustomerHealthDimensionCatalogTest.php tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/CustomerHealth/CustomerHealthDashboardWidgetsTest.php tests/Feature/System/CustomerHealth/CustomerHealthExplainabilityTest.php tests/Feature/System/CustomerHealth/CustomerHealthAuthorizationTest.php`
|
|
||||||
- **Fixture / helper / factory / seed / context cost risks**: reuse existing workspaces, tenants, provider connections, onboarding sessions, telemetry events, operation runs, findings, and review packs; avoid browser setup and avoid new heavy support fixtures
|
|
||||||
- **Expensive defaults or shared helper growth introduced?**: no
|
|
||||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
|
||||||
- **Surface-class relief / special coverage rule**: standard-native-filament relief
|
|
||||||
- **Closing validation and reviewer handoff**: reviewers should confirm unknown handling, dominant reason explainability, platform-safe linking, absence of new persistence, and explicit selected-window semantics
|
|
||||||
- **Disclosure-ladder proof**: reviewers should also confirm that default-visible dashboard content stays operator-first, raw or support-grade detail remains behind linked surfaces, and the two widgets do not duplicate the same visible decision summary
|
|
||||||
- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep
|
|
||||||
- **Review-stop questions**: did the implementation add a persisted score model, customer-facing route, or second badge taxonomy; do unknown and recent-review-pack-request cases degrade honestly?
|
|
||||||
- **Escalation path**: `reject-or-split` if the slice expands into CRM, billing, predictive scoring, or a new portfolio workbench page
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Test-governance outcome**: keep
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Documentation (this feature)
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/245-customer-health-score/
|
|
||||||
├── checklists/
|
|
||||||
│ └── requirements.md
|
|
||||||
├── spec.md
|
|
||||||
├── plan.md
|
|
||||||
└── tasks.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source Code (repository root)
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/platform/
|
|
||||||
├── app/
|
|
||||||
│ ├── Filament/
|
|
||||||
│ │ └── System/
|
|
||||||
│ │ ├── Pages/
|
|
||||||
│ │ │ ├── Dashboard.php
|
|
||||||
│ │ │ └── Directory/
|
|
||||||
│ │ │ ├── ViewTenant.php
|
|
||||||
│ │ │ └── ViewWorkspace.php
|
|
||||||
│ │ └── Widgets/
|
|
||||||
│ │ ├── CustomerHealthKpis.php
|
|
||||||
│ │ └── CustomerHealthTopWorkspaces.php
|
|
||||||
│ ├── Services/
|
|
||||||
│ │ └── Providers/ProviderConnectionStateProjector.php
|
|
||||||
│ └── Support/
|
|
||||||
│ ├── CustomerHealth/
|
|
||||||
│ │ ├── CustomerHealthDimensionCatalog.php
|
|
||||||
│ │ └── WorkspaceHealthSummaryQuery.php
|
|
||||||
│ ├── ProductTelemetry/ProductTelemetrySummaryQuery.php
|
|
||||||
│ └── SystemConsole/
|
|
||||||
├── resources/views/filament/system/pages/directory/
|
|
||||||
│ ├── view-tenant.blade.php
|
|
||||||
│ └── view-workspace.blade.php
|
|
||||||
├── resources/views/filament/system/widgets/customer-health-top-workspaces.blade.php
|
|
||||||
└── tests/
|
|
||||||
├── Unit/Support/CustomerHealth/
|
|
||||||
│ ├── CustomerHealthDimensionCatalogTest.php
|
|
||||||
│ └── WorkspaceHealthSummaryQueryTest.php
|
|
||||||
└── Feature/System/CustomerHealth/
|
|
||||||
├── CustomerHealthDetailDecisionTest.php
|
|
||||||
├── CustomerHealthAuthorizationTest.php
|
|
||||||
├── CustomerHealthDashboardWidgetsTest.php
|
|
||||||
└── CustomerHealthExplainabilityTest.php
|
|
||||||
```
|
|
||||||
|
|
||||||
**Structure Decision**: Single Laravel web application. The implementation adds one bounded support namespace and two dashboard widgets only.
|
|
||||||
|
|
||||||
## Complexity Tracking
|
|
||||||
|
|
||||||
No constitution violations are required. The slice adds one derived summary path only and introduces no new persistence, no new state family, and no new workflow surface.
|
|
||||||
|
|
||||||
## Rollout & Risk Controls
|
|
||||||
|
|
||||||
- Start on the existing `/system` dashboard only; do not introduce a new portfolio or customer-health page in v1.
|
|
||||||
- Keep the first-slice dimension inventory fixed at six. Any added dimension requires a spec update.
|
|
||||||
- Keep review-pack readiness narrow and operational, not programmatic or compliance-heavy.
|
|
||||||
- Use explicit copy or visual grouping to distinguish point-in-time signals from windowed signals.
|
|
||||||
- Guarantee one platform-safe next link per attention-needed workspace row by falling back to the existing system tenant-detail context when a more specific platform-plane route is not appropriate.
|
|
||||||
|
|
||||||
## Implementation Outline
|
|
||||||
|
|
||||||
- Add `App\Support\CustomerHealth\CustomerHealthDimensionCatalog` as the single source for first-slice dimension labels, order, and level precedence rules.
|
|
||||||
- Add `App\Support\CustomerHealth\WorkspaceHealthSummaryQuery` to derive per-workspace summaries from existing onboarding, provider, telemetry, operations, findings, and review-pack truth.
|
|
||||||
- Add `App\Filament\System\Widgets\CustomerHealthKpis` for aggregate health counts.
|
|
||||||
- Add `App\Filament\System\Widgets\CustomerHealthTopWorkspaces` plus a small Blade view for the attention-needed list.
|
|
||||||
- Add one read-only customer-health decision card to the existing system tenant and workspace residual detail pages using the existing summary query and the same dominant drivers as the dashboard.
|
|
||||||
- Register both widgets on the existing `App\Filament\System\Pages\Dashboard` and reuse existing system-safe link helpers only.
|
|
||||||
|
|
||||||
## Implementation Phases
|
|
||||||
|
|
||||||
1. **Foundation**: fixed dimension catalog + derived workspace summary query with core unknown-handling and review-pack-readiness rules + unit proof
|
|
||||||
2. **Dashboard summary**: aggregate KPI widget + system dashboard registration + feature proof
|
|
||||||
3. **Attention list**: compact unhealthy-workspace widget + system-safe links + explainability proof
|
|
||||||
4. **Detail follow-up**: residual system tenant/workspace detail card + preserved window context + follow-up rendering proof
|
|
||||||
5. **Safety hardening**: archived workspace plus tenant exclusions, deterministic ordering hardening, and authorization proof
|
|
||||||
|
|
||||||
## Constitution Check (Post-Design)
|
|
||||||
|
|
||||||
Re-check result: PASS. The plan stays derived-only, reuses existing system dashboard and health presentation semantics, keeps Filament v5 + Livewire v4 intact, leaves provider registration in `bootstrap/providers.php` untouched, introduces no global-search or asset changes, adds no destructive actions, and keeps proof in narrow unit + feature lanes only.
|
|
||||||
@ -1,339 +0,0 @@
|
|||||||
# Feature Specification: Customer Health Score
|
|
||||||
|
|
||||||
**Feature Branch**: `245-customer-health-score`
|
|
||||||
**Created**: 2026-04-27
|
|
||||||
**Status**: Ready for implementation
|
|
||||||
**Input**: User description: "Promote the roadmap-fit candidate Customer Health Score as a narrow, implementation-ready slice that derives an explainable workspace health summary from existing telemetry, onboarding readiness, provider connection health, operation outcomes, findings pressure, and review-pack readiness, then surfaces one attention-oriented summary on the existing system dashboard without introducing CRM, predictive scoring, billing collection, or customer-facing account-management workflows."
|
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
|
||||||
|
|
||||||
- **Problem**: TenantPilot still requires founder-style manual inspection across onboarding, provider connections, failed runs, findings, review packs, and telemetry to understand which workspaces actually need attention.
|
|
||||||
- **Today's failure**: The product exposes isolated health clues, but no explainable workspace-level summary. Operators must reconstruct risk by hopping between `/system`, tenant directory, run lists, and tenant surfaces, which delays support and masks silent customer/workspace deterioration.
|
|
||||||
- **User-visible improvement**: A platform operator can open the existing `/system` dashboard and immediately see which workspaces are healthy, which need attention, why they need attention, and which existing platform-safe surface to open next. When the operator follows that link into the existing system tenant or workspace detail, the page repeats the decision-first health explanation above the existing diagnostics.
|
|
||||||
- **Smallest enterprise-capable version**: Add one derived customer-health query path with six fixed first-slice dimensions, reuse the existing system dashboard and window selector, show aggregate health counts plus an attention-needed workspace list, and keep the entire slice read-only and non-persistent.
|
|
||||||
- **Explicit non-goals**: No CRM or customer-success suite, no billing collection or entitlement enforcement, no predictive churn model, no customer-facing health portal, no tenant/admin-plane health viewer, no new persisted score table, no background recomputation job, and no AI-generated account-management actions.
|
|
||||||
- **Permanent complexity imported**: One bounded `CustomerHealth` support namespace, one derived summary query path, one small fixed dimension catalog, two dashboard widgets, and focused unit plus feature coverage.
|
|
||||||
- **Why now**: Self-Service Tenant Onboarding & Connection Readiness, Support Diagnostic Pack, Operational Controls, Product Usage & Adoption Telemetry, and Product Knowledge are now specced or implemented. Customer Health Score is the smallest next slice that turns those foundations into one operator-facing attention signal before lifecycle communication, AI assistance, or broader portfolio workflows expand.
|
|
||||||
- **Why not local**: Local counters on the system dashboard or one-off health badges in the directory would either duplicate logic across surfaces or hide how onboarding, provider, operational, governance, review-pack, and adoption truth combine into one explainable workspace summary.
|
|
||||||
- **Approval class**: Core Enterprise
|
|
||||||
- **Red flags triggered**: New abstraction, many-signal summary. Defense: the slice stays derived-only, reuses the existing `SystemHealth` level language, fixes the first-slice dimension inventory at six signals, and limits UI impact to the existing `/system` dashboard.
|
|
||||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
|
||||||
- **Decision**: approve
|
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
|
||||||
|
|
||||||
- **Scope**: platform, workspace, tenant
|
|
||||||
- **Primary Routes**:
|
|
||||||
- `/system` existing system dashboard for aggregate health counts and the attention-needed workspace list
|
|
||||||
- existing platform-safe deep links from those widgets into the system tenant directory and system operations surfaces
|
|
||||||
- existing `/system/directory/tenants/{tenant}` and `/system/directory/workspaces/{workspace}` residual detail pages as read-only health follow-up surfaces
|
|
||||||
- **Data Ownership**: No new persisted customer-health truth is introduced. The summary is derived from existing tenant-owned product truths such as `product_usage_events`, `provider_connections`, `operation_runs`, `findings`, `review_packs`, and onboarding-readiness state already owned by existing onboarding/session/provider records.
|
|
||||||
- **RBAC**: Reads remain platform-plane only through the existing `/system` dashboard access gate. No tenant/admin-plane or customer-facing health surface is introduced in this slice.
|
|
||||||
|
|
||||||
For canonical-view specs, the spec MUST define:
|
|
||||||
|
|
||||||
- **Default filter behavior when tenant-context is active**: N/A - the first slice is a platform-plane dashboard addition under `/system`, not a tenant-context admin view.
|
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: The slice exposes derived counts and system-safe drilldown links only to existing platform users who can already access the system dashboard. It does not expose raw tenant-owned health rows or create any cross-plane deep link into `/admin` tenant surfaces.
|
|
||||||
|
|
||||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
|
||||||
|
|
||||||
- **Cross-cutting feature?**: yes
|
|
||||||
- **Interaction class(es)**: dashboard signals/cards, read-only attention list, system-safe deep links, status badges
|
|
||||||
- **Systems touched**: `App\Filament\System\Pages\Dashboard`, existing system dashboard widget family, `App\Support\ProductTelemetry\ProductTelemetrySummaryQuery`, `App\Services\Providers\ProviderConnectionStateProjector`, `App\Support\SystemConsole\StuckRunClassifier`, existing `Finding` and `ReviewPack` truth queries, and existing system link helpers
|
|
||||||
- **Existing pattern(s) to extend**: the `/system` dashboard widget composition, the existing dashboard time-window selector, `ControlTowerHealthIndicator`, and `ControlTowerTopOffenders`
|
|
||||||
- **Shared contract / presenter / builder / renderer to reuse**: `StatsOverviewWidget`, existing custom system widgets, `BadgeRenderer` with `BadgeDomain::SystemHealth`, `SystemConsoleWindow`, `SystemOperationRunLinks`, and existing system directory links
|
|
||||||
- **Why the existing shared path is sufficient or insufficient**: Existing shared paths already solve dashboard placement, read-only system-plane rendering, time-window selection, and system-safe navigation. They are insufficient because none of them currently derives one explainable workspace-health summary from multiple existing truths.
|
|
||||||
- **Allowed deviation and why**: One bounded `CustomerHealth` support namespace is allowed to centralize health derivation. Page-local health arithmetic, widget-local badge vocabularies, or duplicated query seams are not allowed.
|
|
||||||
- **Consistency impact**: Overall health levels must reuse the existing `SystemHealth` vocabulary (`ok`, `warn`, `critical`, `unknown`), and the first-slice dimension labels plus time-basis copy must remain consistent between summary widgets and any linked system-safe detail flow.
|
|
||||||
- **Review focus**: Reviewers must verify that the slice stays derived-only, does not add a hidden score table, does not invent a second badge taxonomy, does not create platform-to-admin deep links, and does not broaden into customer-success or billing workflow scope.
|
|
||||||
|
|
||||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
|
||||||
|
|
||||||
- **Touches OperationRun start/completion/link UX?**: no
|
|
||||||
- **Shared OperationRun UX contract/layer reused**: N/A - the slice reads existing run truth only and may link to existing system run lists.
|
|
||||||
- **Delegated start/completion UX behaviors**: N/A
|
|
||||||
- **Local surface-owned behavior that remains**: N/A
|
|
||||||
- **Queued DB-notification policy**: N/A
|
|
||||||
- **Terminal notification path**: N/A
|
|
||||||
- **Exception required?**: none
|
|
||||||
|
|
||||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
|
||||||
|
|
||||||
- **Shared provider/platform boundary touched?**: yes
|
|
||||||
- **Boundary classification**: mixed
|
|
||||||
- **Seams affected**: provider verification and consent statuses as one health-dimension input, review-pack status labels, telemetry family activity, and system-safe health copy
|
|
||||||
- **Neutral platform terms preserved or introduced**: customer health, workspace health, health dimension, attention needed, engagement freshness, review readiness, operational stability
|
|
||||||
- **Provider-specific semantics retained and why**: Microsoft-specific provider consent and verification outcomes remain inside the provider-health dimension because they are existing current-release truth owned by provider-connection workflows.
|
|
||||||
- **Why this does not deepen provider coupling accidentally**: The top-level summary stays platform-owned and explainable. Provider-specific codes and statuses are consumed only as one input dimension and are not promoted into a new platform taxonomy.
|
|
||||||
- **Follow-up path**: Customer Lifecycle Communication and later portfolio workflows may reuse the summary, but they remain separate specs.
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
|
||||||
|
|
||||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| System dashboard customer-health KPI widget | yes | Native Filament + shared stats widget | dashboard signals/cards | page, widget, window query | no | Read-only KPI addition on existing `/system` dashboard |
|
|
||||||
| System dashboard attention-needed workspace widget | yes | Native system widget family with custom Blade view | dashboard signals/cards, read-only attention list, navigation links | page, widget, detail-link state | no | Compact list of unhealthy workspaces only; no new page or workbench |
|
|
||||||
| System tenant and workspace residual detail pages customer-health decision card | yes | Existing custom system detail pages | linked follow-up context, decision-first explanation | page, window query | no | Read-only summary card above existing diagnostics; no new page or mutation |
|
|
||||||
|
|
||||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| System dashboard customer-health KPI widget | Secondary Context Surface | Decide whether overall platform attention is rising and whether to inspect unhealthy workspaces now | Count of healthy, warning, critical, and unknown workspaces for the selected window | Existing system tenant directory, operations list, and source surfaces remain evidence | Secondary because it frames attention, not the underlying remediation workflow | Fits the founder or platform-operator control-tower loop | Replaces manual reconstruction across onboarding, telemetry, runs, and findings surfaces |
|
|
||||||
| System dashboard attention-needed workspace widget | Secondary Context Surface | Decide which workspace to inspect next and which source truth is driving the problem | Workspace name, overall level, dominant dimensions, and one platform-safe next link | Existing system-safe detail surfaces and linked source contexts | Secondary because the actual action still happens on existing system detail and run surfaces | Keeps the dashboard as a triage layer, not a second operations workbench | Surfaces the next workspace to inspect without requiring freeform database or log inspection |
|
|
||||||
| System tenant and workspace residual detail pages customer-health decision card | Primary Decision Context | Decide why this workspace is `critical`, `warn`, or `unknown` before reading lower-level diagnostics | Overall level, repeated top drivers, operator impact, and recommended next action | Existing connectivity, permissions, tenant, and recent-run sections remain the lower-level evidence | Primary because the card turns the residual detail page into an explain-first follow-up instead of a diagnostic puzzle | Keeps `/system` drilldowns decision-first while preserving the existing read-mostly detail shape | Removes the need to infer the health reason from several independent sections |
|
|
||||||
|
|
||||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| System dashboard customer-health KPI widget | Dashboard / Overview / KPI widget | Read-only operational summary | Decide whether there is platform-wide attention pressure | In-page stat cards | forbidden | none | none | `/system` | `/system` | Existing dashboard time window and health-level counts | Customer health / Workspace health | Current health-level distribution for visible workspaces | none |
|
|
||||||
| System dashboard attention-needed workspace widget | Dashboard / Overview / compact list widget | Read-only attention registry | Open the most relevant existing system-safe detail surface for one unhealthy workspace | Named link per workspace row | forbidden | One explicit platform-safe next link per row | none | `/system` | existing linked system detail surfaces only | Workspace label, dominant dimensions, and selected time window | Attention-needed workspaces / Workspace | Which workspace needs review next and why | Compact dashboard widget justified because the list is triage-only, not a new registry page |
|
|
||||||
|
|
||||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| System dashboard customer-health KPI widget | Platform operator | Decide whether customer or workspace health is deteriorating overall | Dashboard summary | How many workspaces are healthy, unknown, warning, or critical right now? | Level counts, selected time window, and clear empty or unknown handling | Raw contributing rows, provider-specific details, and per-tenant evidence stay out of the widget | overall health level only | none | existing dashboard time-window selection only | none |
|
|
||||||
| System dashboard attention-needed workspace widget | Platform operator | Decide which workspace to inspect next and what kind of issue is driving it | Compact attention list | Which workspace needs attention first, and what is the dominant reason? | Workspace label, overall level, top one or two non-ok dimensions, and one system-safe next link | Full source record sets, provider codes, detailed finding counts, and review-pack internals remain in linked surfaces | overall level plus dominant health dimensions | none | open linked system-safe detail surface | none |
|
|
||||||
| System tenant and workspace residual detail pages customer-health decision card | Platform operator | Decide what to review first on the current detail page | Read-only follow-up summary | Why is this workspace unhealthy or unknown, and what should I inspect next? | Overall health, repeated top drivers, operator impact, recommended next action, and preserved selected-window context for windowed dimensions | Lower-level connectivity, permission, tenant, and recent-run evidence remains in the existing sections below | overall level plus dominant health dimensions | none | open existing linked follow-up surfaces only when already present on the page | none |
|
|
||||||
|
|
||||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
|
||||||
|
|
||||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| System dashboard page | `App\Filament\System\Pages\Dashboard` | Existing `Time window` header action only; no new header actions | n/a | n/a | none | n/a | n/a | n/a | no | Action Surface Contract remains satisfied because the feature adds read-only widgets only and does not alter the page-level mutation model |
|
|
||||||
| Attention-needed workspace widget | `App\Filament\System\Widgets\CustomerHealthTopWorkspaces` | none | One named platform-safe next link per row; row click remains forbidden | Max one visible next link, chosen as `Review health details` or `Open runs` based on the dominant reason | none | One explanatory empty state with no CTA when no workspace needs attention | n/a | n/a | no | Every row must still expose one platform-safe next link. If no more specific route fits, the row falls back to the existing system tenant-detail context rather than inventing a new surface or showing no link |
|
|
||||||
|
|
||||||
**List-surface standard reference:** The attention-needed workspace widget is a compact list surface and MUST be reviewed against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/docs/product/standards/list-surface-review-checklist.md` before implementation sign-off. Accepted v1 exceptions are limited to compact dashboard-widget constraints: no persistence trio, no bulk actions, no row click, and no empty-state CTA because this surface is triage-only rather than a standalone registry page.
|
|
||||||
|
|
||||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
|
||||||
|
|
||||||
- **New source of truth?**: no
|
|
||||||
- **New persisted entity/table/artifact?**: no
|
|
||||||
- **New abstraction?**: yes
|
|
||||||
- **New enum/state/reason family?**: no - the slice reuses existing `SystemHealth` levels
|
|
||||||
- **New cross-domain UI framework/taxonomy?**: no
|
|
||||||
- **Current operator problem**: the platform has no single explainable workspace-health summary even though the underlying truth already exists in onboarding, provider, operations, findings, review packs, and telemetry.
|
|
||||||
- **Existing structure is insufficient because**: the existing structures each explain one domain only. None can honestly answer which workspace is unhealthy overall without duplicating cross-domain query logic or forcing manual reconstruction.
|
|
||||||
- **Narrowest correct implementation**: add one bounded derived-query path and one small fixed dimension catalog, then surface it on the existing system dashboard only.
|
|
||||||
- **Ownership cost**: one support namespace, two dashboard widgets, fixed dimension rules, and focused unit plus feature coverage.
|
|
||||||
- **Alternative intentionally rejected**: a persisted score table, a customer-success page, a background recomputation pipeline, or an opaque weighted scoring engine.
|
|
||||||
- **Release truth**: current-release truth
|
|
||||||
|
|
||||||
### Compatibility posture
|
|
||||||
|
|
||||||
This feature assumes a pre-production environment.
|
|
||||||
|
|
||||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
|
||||||
|
|
||||||
Canonical replacement is preferred over preservation.
|
|
||||||
|
|
||||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
|
||||||
|
|
||||||
- **Test purpose / classification**: Unit, Feature
|
|
||||||
- **Validation lane(s)**: fast-feedback, confidence
|
|
||||||
- **Why this classification and these lanes are sufficient**: Unit tests can prove health-level derivation, dimension precedence, unknown handling, and signal inclusion rules. Feature tests can prove dashboard rendering, platform-plane authorization, and system-safe link behavior without browser automation.
|
|
||||||
- **New or expanded test families**: One focused `CustomerHealth` unit family plus a small set of `/system` feature tests for summary widgets, explainability, and authorization.
|
|
||||||
- **Fixture / helper cost impact**: Moderate. Reuse existing workspaces, tenants, provider connections, onboarding sessions, product-usage events, operation runs, findings, and review packs. Avoid new browser helpers, seeded dashboards, or heavy system defaults.
|
|
||||||
- **Heavy-family visibility / justification**: none
|
|
||||||
- **Special surface test profile**: standard-native-filament
|
|
||||||
- **Standard-native relief or required special coverage**: standard native Filament feature coverage is sufficient because the slice adds read-only widgets only.
|
|
||||||
- **Reviewer handoff**: Reviewers must confirm that the health summary is derived-only, that `unknown` and stale data are not silently treated as healthy, that system-safe links remain on the platform plane, that no new persistence appears, and that the selected window affects only the intended windowed dimensions.
|
|
||||||
- **Budget / baseline / trend impact**: Low-to-moderate increase in narrow unit plus feature coverage only.
|
|
||||||
- **Escalation needed**: none
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Test-governance outcome**: keep
|
|
||||||
- **Planned validation commands**:
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/CustomerHealthDimensionCatalogTest.php tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/CustomerHealth/CustomerHealthDashboardWidgetsTest.php tests/Feature/System/CustomerHealth/CustomerHealthExplainabilityTest.php tests/Feature/System/CustomerHealth/CustomerHealthAuthorizationTest.php`
|
|
||||||
|
|
||||||
## Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The system MUST derive one workspace-health summary from exactly six first-slice dimensions: onboarding readiness, provider connection health, operational stability, governance pressure, review-pack readiness, and engagement freshness.
|
|
||||||
- **FR-002**: The system MUST compute the overall workspace health level from existing `SystemHealth` semantics with explicit precedence: `critical` outranks `warn`, `warn` outranks `unknown`, and `unknown` outranks `ok`.
|
|
||||||
- **FR-003**: The system MUST keep the first slice derived-only. No persisted score table, asynchronous recomputation store, or secondary health ledger may be introduced.
|
|
||||||
- **FR-004**: The existing `/system` dashboard MUST show aggregate health counts and an attention-needed workspace list using the existing dashboard window selector for windowed dimensions.
|
|
||||||
- **FR-005**: The system MUST keep platform-safe navigation boundaries. Health widgets may link only to existing platform-plane system surfaces, never directly to tenant-admin `/admin` routes.
|
|
||||||
- **FR-006**: The system MUST make stale or missing source truth explicit. Missing or stale inputs must not silently produce `ok`.
|
|
||||||
- **FR-007**: Review-pack readiness in the first slice MUST stay narrow: it may reflect recent review-pack requests and the latest relevant pack status, but it must not become a quarterly review-obligation engine.
|
|
||||||
- **FR-008**: The slice MUST not introduce any new customer-facing communication, entitlement, billing, or AI workflow.
|
|
||||||
|
|
||||||
## Non-Functional Requirements
|
|
||||||
|
|
||||||
- **NFR-001**: The summary query must stay bounded and reusable from the existing system dashboard without new background jobs or remote calls.
|
|
||||||
- **NFR-002**: Widget copy must remain calm, explainable, and dashboard-first rather than CRM-like, sales-like, or predictive.
|
|
||||||
- **NFR-003**: Health-level rendering must reuse existing badge or status semantics instead of inventing a second color or severity language.
|
|
||||||
|
|
||||||
## UX Requirements
|
|
||||||
|
|
||||||
- **UX-001**: The dashboard must remain triage-first. Summary counts and dominant reasons are visible by default; raw counts and low-level diagnostics stay behind existing linked surfaces.
|
|
||||||
- **UX-002**: The slice must not become a new workbench page. `/system` remains the only first-slice entry point, and any health follow-up must stay inside existing residual detail pages rather than creating a new health detail page.
|
|
||||||
- **UX-003**: The widget and the linked residual detail decision card must distinguish point-in-time dimensions from windowed dimensions in their copy or presentation so operators do not assume one false time basis.
|
|
||||||
|
|
||||||
## RBAC / Security Requirements
|
|
||||||
|
|
||||||
- **SEC-001**: Only existing platform users who can already access `/system` may see customer-health widgets.
|
|
||||||
- **SEC-002**: No tenant/admin-plane, customer-facing, or raw-row viewer may be introduced in this slice.
|
|
||||||
- **SEC-003**: System-safe deep links must remain guarded by existing platform-plane route policies and must not reveal tenant-admin paths or scoped data to the wrong plane.
|
|
||||||
|
|
||||||
## Auditability / Observability Requirements
|
|
||||||
|
|
||||||
- **OBS-001**: The slice must reuse existing product truths and must not duplicate audit entries or OperationRun records for derived reads.
|
|
||||||
- **OBS-002**: The source dimension set and level precedence must be testable so future changes cannot silently redefine health semantics.
|
|
||||||
|
|
||||||
## Data / Truth-Source Requirements
|
|
||||||
|
|
||||||
- **DATA-001**: Onboarding readiness must reuse existing onboarding-session and readiness truth rather than a copied health field.
|
|
||||||
- **DATA-002**: Provider connection health must reuse provider-owned verification and consent truth rather than a new platform-owned provider score.
|
|
||||||
- **DATA-003**: Operational stability must reuse existing failed and stuck `OperationRun` truth within the selected `SystemConsoleWindow`.
|
|
||||||
- **DATA-004**: Governance pressure must reuse existing findings and risk-acceptance truth.
|
|
||||||
- **DATA-005**: Review-pack readiness must reuse existing review-pack truth plus recent request context where needed.
|
|
||||||
- **DATA-006**: Engagement freshness must reuse existing `product_usage_events` truth rather than page views or ad-hoc counters.
|
|
||||||
- **DATA-007**: Archived workspaces must be excluded from active portfolio counts, and archived tenants must not contribute source truth into active workspace-health derivation.
|
|
||||||
|
|
||||||
## Review-Pack Readiness Rule
|
|
||||||
|
|
||||||
`Recent` means the currently selected dashboard window. Request context is derived from existing review-pack request telemetry when available, falling back to a `ReviewPack` created for the same workspace inside that same window.
|
|
||||||
|
|
||||||
| Condition | Review-pack readiness level | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| No request context and no relevant pack activity in the selected window | `unknown` | The first slice does not infer quarterly review obligations from silence |
|
|
||||||
| Request context exists in the selected window and no relevant pack is usable yet | `warn` | Covers queued, running, or not-yet-materialized pack generation |
|
|
||||||
| Latest relevant pack in the selected window is ready and not expired | `ok` | A usable recent pack exists |
|
|
||||||
| Latest relevant pack in the selected window is failed or expired | `critical` | Recent review-pack work ended unusably and needs attention |
|
|
||||||
|
|
||||||
## Attention Ordering Rule
|
|
||||||
|
|
||||||
The attention-needed workspace list is deterministic in v1:
|
|
||||||
|
|
||||||
1. Higher overall health severity sorts first: `critical` before `warn` before `unknown`
|
|
||||||
2. Within the same overall level, workspaces with more non-`ok` dimensions sort first
|
|
||||||
3. Remaining ties sort by workspace name ascending, then workspace id ascending
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - See Portfolio Health At A Glance (Priority: P1)
|
|
||||||
|
|
||||||
As a platform operator, I need the existing `/system` dashboard to summarize customer or workspace health so I can tell quickly whether attention pressure is rising.
|
|
||||||
|
|
||||||
**Why this priority**: Without an aggregate signal, operators still need to inspect several unrelated surfaces before they even know whether a health problem exists.
|
|
||||||
|
|
||||||
**Independent Test**: Seed workspaces with different combinations of healthy, warning, critical, and unknown source truth and verify that `/system` renders the correct health-level counts, explicit unknown handling, and a visible cue for windowed versus point-in-time dimensions.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** multiple visible workspaces with mixed source truth, **When** an authorized platform user opens `/system`, **Then** the dashboard shows aggregate counts for `ok`, `warn`, `critical`, and `unknown` workspaces using the first-slice health rules.
|
|
||||||
2. **Given** a workspace has only stale or missing source truth for one or more dimensions, **When** the dashboard renders, **Then** that workspace is not shown as healthy by default.
|
|
||||||
3. **Given** the summary mixes operational and point-in-time signals, **When** the dashboard renders, **Then** the widget shows a visible cue that operations and engagement honor the selected window while onboarding, provider, governance, and review-readiness remain state-based.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Review Attention-Needed Workspaces With Explainable Reasons (Priority: P1)
|
|
||||||
|
|
||||||
As a platform operator, I need a compact list of the workspaces that need attention most so I know where to inspect next and why.
|
|
||||||
|
|
||||||
**Why this priority**: Aggregate counts alone do not reduce support load unless operators can immediately identify which workspace needs review and what category of issue is driving that state.
|
|
||||||
|
|
||||||
**Independent Test**: Seed multiple unhealthy workspaces and confirm the dashboard lists them with overall health level, dominant dimensions, and one platform-safe next link per row.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** multiple warning or critical workspaces, **When** the dashboard renders, **Then** the attention-needed widget lists the worst workspaces first with overall level and dominant health dimensions.
|
|
||||||
2. **Given** a workspace row offers a next action, **When** the user activates that action, **Then** the link stays on an existing platform-plane system surface and does not deep-link into `/admin` tenant routes.
|
|
||||||
3. **Given** the operator follows a health-detail link from the dashboard, **When** the linked system tenant or workspace detail page renders, **Then** a decision-first customer-health card appears above the existing diagnostics and repeats the same dominant health drivers together with overall level, impact, and recommended next action.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Keep Health Honest And Narrow (Priority: P2)
|
|
||||||
|
|
||||||
As the product owner, I need the health summary to stay explainable and bounded so it does not become an opaque score, a hidden persistence layer, or a substitute for existing source truth.
|
|
||||||
|
|
||||||
**Why this priority**: A misleading or overbuilt score would add maintenance and trust debt immediately.
|
|
||||||
|
|
||||||
**Independent Test**: Inspect the derived summary rules and verify that unknown or mixed-time-basis inputs remain explicit, no new persistence appears, and no customer-facing workflow is introduced.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the first-slice dimension rules are evaluated, **When** one dimension is `critical` and another is `unknown`, **Then** the overall workspace level resolves deterministically and the dominant reasons remain explainable.
|
|
||||||
2. **Given** recent review-pack generation was requested but no usable pack is yet available, **When** the review-readiness dimension is evaluated, **Then** the workspace is not falsely shown as healthy.
|
|
||||||
3. **Given** a user without existing `/system` access attempts to read customer health, **When** they hit the surface, **Then** access stays denied through the existing platform-plane gate.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- Archived workspaces or tenants must not inflate active health counts.
|
|
||||||
- A workspace can have healthy current provider state but no recent engagement telemetry; the slice must make the time basis explicit instead of collapsing that into a misleading single timestamp.
|
|
||||||
- A recent review-pack request can exist before a ready pack exists; that gap must not render as healthy.
|
|
||||||
- A workspace with no onboarding-linked tenant yet must remain `unknown` or explicitly non-healthy rather than silently `ok`.
|
|
||||||
- Mixed workspace truth across several tenants must still produce one explainable workspace-level result without exposing raw tenant-owned rows on the dashboard.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- The first slice derives workspace health from six fixed dimensions only and keeps all health truth derived from existing records.
|
|
||||||
- The existing `/system` dashboard exposes both aggregate health counts and an attention-needed workspace list.
|
|
||||||
- Operators can see dominant reasons for unhealthy workspaces without opening a second system page first.
|
|
||||||
- Unknown or stale source truth is explicit and never silently treated as healthy.
|
|
||||||
- The slice introduces no new persisted health entity, no customer-facing view, and no new platform-to-admin deep link.
|
|
||||||
|
|
||||||
## Out Of Scope
|
|
||||||
|
|
||||||
- Customer-facing health portals
|
|
||||||
- Billing or entitlement-aware health dimensions
|
|
||||||
- Automated customer lifecycle messaging
|
|
||||||
- Predictive scoring, churn scoring, or AI-generated recommendations
|
|
||||||
- A dedicated portfolio-health page outside the existing `/system` dashboard
|
|
||||||
- A new persisted workspace-health model or scheduled recomputation job
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
- A platform operator can identify whether any workspace needs attention, and which workspace to inspect first, within one `/system` dashboard visit.
|
|
||||||
- The first slice remains explainable enough that each visible non-healthy state can be tied back to one or two named dimensions.
|
|
||||||
- The package stays implementation-ready without introducing unresolved product decisions around billing, CRM, AI, or customer-facing UX.
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- Product Usage & Adoption Telemetry is available or implemented before this slice is delivered.
|
|
||||||
- Existing system dashboard access rules remain the correct audience gate for the first slice.
|
|
||||||
- Existing provider-connection, finding, review-pack, and run surfaces remain the source-of-truth owners for detailed inspection.
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- Too many first-slice dimensions would make the dashboard noisy; the dimension inventory is fixed at six for v1.
|
|
||||||
- Mixed point-in-time and windowed signals can confuse operators if the UI does not label them carefully.
|
|
||||||
- Review-pack readiness could drift into a broader compliance-workflow interpretation if it is not kept narrow.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
- None blocking the first slice. The v1 contract guarantees one platform-safe next link per workspace row by falling back to the existing system tenant-detail context when a more specific platform-plane route is not appropriate.
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
**Constitution alignment (required):** The slice adds no Microsoft Graph calls, no mutation flow, and no new queued or scheduled work. It derives read-only health summaries from existing onboarding, provider, telemetry, findings, review-pack, and `OperationRun` truth only.
|
|
||||||
|
|
||||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces no new persistence and reuses existing health-level semantics. The only new structure is one bounded derived-query path plus a fixed dimension catalog because current-release operator workflow now needs a single explainable workspace-health summary.
|
|
||||||
|
|
||||||
**Constitution alignment (XCUT-001):** This slice explicitly extends the shared system dashboard widget family, system-safe links, and shared `SystemHealth` presentation instead of inventing a second health dashboard language.
|
|
||||||
|
|
||||||
**Constitution alignment (PROV-001):** Provider-specific verification and consent semantics remain provider-owned inputs only; the top-level customer-health summary stays platform-owned and neutral.
|
|
||||||
|
|
||||||
**Constitution alignment (TEST-GOV-001):** Proof stays in Unit + Feature lanes only. No heavy-governance or browser family is justified.
|
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX):** Existing `OperationRun` start, completion, notification, and link semantics remain unchanged. The slice reads existing run truth and may link to existing system operations surfaces only.
|
|
||||||
|
|
||||||
**Constitution alignment (RBAC-UX):** Reads remain platform-plane only through existing `/system` access checks. No tenant/admin or customer-facing health viewer is introduced.
|
|
||||||
|
|
||||||
**Constitution alignment (BADGE-001):** The feature reuses existing `BadgeDomain::SystemHealth` rendering and does not add a new badge taxonomy.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-FIL-001):** The only operator-facing changes are native Filament system widgets on the existing dashboard. No published views, panel provider changes, or custom asset pipeline changes are introduced.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** Widget labels and dominant health reasons must remain operator-first and platform-neutral, such as `Customer health`, `Attention-needed workspaces`, `Provider health`, `Operational stability`, and `Engagement freshness`.
|
|
||||||
|
|
||||||
**Filament v5 / Livewire v4 compliance:** The slice remains fully inside the current Filament v5 + Livewire v4 stack.
|
|
||||||
|
|
||||||
**Provider registration location:** No provider registration changes are introduced; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
|
|
||||||
|
|
||||||
**Global search rule:** No new resource or global-search participation is introduced.
|
|
||||||
|
|
||||||
**Destructive actions:** No destructive actions are added in this slice.
|
|
||||||
|
|
||||||
**Asset strategy:** No new global or on-demand assets are added. Deployment behavior for `filament:assets` remains unchanged.
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
---
|
|
||||||
|
|
||||||
description: "Task list for Customer Health Score"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Tasks: Customer Health Score
|
|
||||||
|
|
||||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/245-customer-health-score/`
|
|
||||||
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/245-customer-health-score/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/245-customer-health-score/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/245-customer-health-score/checklists/requirements.md` (required)
|
|
||||||
|
|
||||||
**Tests (TEST-GOV-001)**: REQUIRED (Pest) for all runtime behavior changes in this slice. Keep proof in Unit + Feature lanes only.
|
|
||||||
**Operations**: This slice must not change `OperationRun` start, completion, notification, or link UX.
|
|
||||||
**RBAC**: Existing `/system` dashboard access remains authoritative. No tenant/admin-plane or customer-facing health viewer is introduced.
|
|
||||||
**Organization**: Tasks are grouped by user story so aggregate health counts, the attention-needed workspace list, and edge-case hardening remain independently deliverable. Core unknown-handling lives in the foundational query path.
|
|
||||||
|
|
||||||
## Phase 1: Setup (Shared Infrastructure)
|
|
||||||
|
|
||||||
**Purpose**: Prepare the bounded support namespace and narrow test surfaces for the first slice.
|
|
||||||
|
|
||||||
- [x] T001 Create the feature-local support namespace and test directories under `apps/platform/app/Support/CustomerHealth/`, `apps/platform/tests/Unit/Support/CustomerHealth/`, and `apps/platform/tests/Feature/System/CustomerHealth/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Prerequisites)
|
|
||||||
|
|
||||||
**Purpose**: Add the single shared dimension catalog and derived summary query before any dashboard UI adoption.
|
|
||||||
|
|
||||||
**Checkpoint**: One bounded, derived-only customer-health path exists before the dashboard starts rendering it, including the base unknown-handling and review-pack-readiness rules.
|
|
||||||
|
|
||||||
- [x] T002 Create the fixed first-slice dimension catalog in `apps/platform/app/Support/CustomerHealth/CustomerHealthDimensionCatalog.php`, reusing existing `SystemHealth` level semantics and locking the first slice to onboarding readiness, provider connection health, operational stability, governance pressure, review-pack readiness, and engagement freshness
|
|
||||||
- [x] T003 Create the derived workspace summary query in `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php` that reads existing onboarding, provider connection, telemetry, `OperationRun`, findings, and review-pack truth without introducing a persisted score model, and bakes in the base unknown-handling plus selected-window review-pack-readiness rules
|
|
||||||
- [x] T004 [P] Add unit coverage for dimension labels, level precedence, unknown handling, the selected-window review-pack-readiness rule table, and windowed versus point-in-time signal rules in `apps/platform/tests/Unit/Support/CustomerHealth/CustomerHealthDimensionCatalogTest.php` and `apps/platform/tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php`
|
|
||||||
- [x] T005 Run the foundational unit suite with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/CustomerHealthDimensionCatalogTest.php tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: User Story 1 - See Portfolio Health At A Glance (Priority: P1) 🎯 MVP
|
|
||||||
|
|
||||||
**Goal**: Show aggregate workspace-health counts on the existing `/system` dashboard.
|
|
||||||
|
|
||||||
**Independent Test**: Seed mixed workspace truth and verify the dashboard renders aggregate `ok`, `warn`, `critical`, and `unknown` counts with explicit unknown handling.
|
|
||||||
|
|
||||||
### Tests for User Story 1
|
|
||||||
|
|
||||||
- [x] T006 [P] [US1] Add dashboard feature coverage for aggregate health counts, unknown handling, selected-window behavior, and a visible time-basis cue in `apps/platform/tests/Feature/System/CustomerHealth/CustomerHealthDashboardWidgetsTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 1
|
|
||||||
|
|
||||||
- [x] T007 [US1] Create the summary stats widget in `apps/platform/app/Filament/System/Widgets/CustomerHealthKpis.php` using the shared `StatsOverviewWidget` pattern and `WorkspaceHealthSummaryQuery`, including visible copy that distinguishes selected-window dimensions from point-in-time dimensions
|
|
||||||
- [x] T008 [US1] Register the new summary widget on `apps/platform/app/Filament/System/Pages/Dashboard.php` without changing the existing system dashboard access gate or header actions
|
|
||||||
- [x] T009 [US1] Run the first-slice dashboard summary proof with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/CustomerHealth/CustomerHealthDashboardWidgetsTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: User Story 2 - Review Attention-Needed Workspaces With Explainable Reasons (Priority: P1)
|
|
||||||
|
|
||||||
**Goal**: List the worst workspaces first with dominant health dimensions and one platform-safe next link.
|
|
||||||
|
|
||||||
**Independent Test**: Seed several unhealthy workspaces and verify the dashboard shows them in priority order with dominant reasons and platform-safe links only.
|
|
||||||
|
|
||||||
### Tests for User Story 2
|
|
||||||
|
|
||||||
- [x] T010 [P] [US2] Add feature coverage for deterministic workspace ordering, dominant-dimension rendering, operator-first default disclosure, absence of raw/support detail on the default dashboard surface, absence of duplicate visible decision summaries across the two widgets, and exactly one platform-safe next link per row with the tenant-detail fallback path in `apps/platform/tests/Feature/System/CustomerHealth/CustomerHealthExplainabilityTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 2
|
|
||||||
|
|
||||||
- [x] T011 [US2] Create the compact attention widget in `apps/platform/app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php`
|
|
||||||
- [x] T012 [US2] Add the companion Blade view in `apps/platform/resources/views/filament/system/widgets/customer-health-top-workspaces.blade.php`, keeping the surface read-only and triage-first
|
|
||||||
- [x] T013 [US2] Use existing platform-plane link helpers inside `apps/platform/app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php` so each row exposes exactly one next link on system directory or system operations surfaces, with fallback to system tenant detail when no more specific platform-plane route is appropriate
|
|
||||||
- [x] T014 [US2] Register the attention-needed widget on `apps/platform/app/Filament/System/Pages/Dashboard.php` without turning `/system` into a second workbench page
|
|
||||||
- [x] T015 [US2] Run the explainability and link proof with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/CustomerHealth/CustomerHealthExplainabilityTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: User Story 3 - Keep Health Honest And Narrow (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: Harden archived-workspace and archived-tenant handling, authorization, and dominant-reason edge cases after the foundational unknown-handling rules are already in place.
|
|
||||||
|
|
||||||
**Independent Test**: Verify that archived workspaces stay out of active attention counts, archived tenants do not drive active workspace health, review-pack edge cases continue to follow the selected-window rule, and unauthorized users cannot read the widgets.
|
|
||||||
|
|
||||||
### Tests for User Story 3
|
|
||||||
|
|
||||||
- [x] T016 [P] [US3] Add feature coverage for `/system` authorization boundaries in `apps/platform/tests/Feature/System/CustomerHealth/CustomerHealthAuthorizationTest.php`
|
|
||||||
- [x] T017 [P] [US3] Extend `apps/platform/tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php` with archived-workspace, archived-tenant, dominant-reason ordering, and recent-review-pack-request edge cases that harden the existing foundational rules
|
|
||||||
|
|
||||||
### Implementation for User Story 3
|
|
||||||
|
|
||||||
- [x] T018 [US3] Harden `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php` for archived-workspace and archived-tenant exclusions, dominant-reason ordering, and review-pack edge cases without moving the core unknown-handling rules out of the foundation
|
|
||||||
- [x] T019 [US3] Keep overall level rendering on existing `BadgeDomain::SystemHealth` semantics inside `apps/platform/app/Filament/System/Widgets/CustomerHealthKpis.php` and `apps/platform/app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php` rather than introducing a new score language
|
|
||||||
- [x] T020 [US3] Run the narrow safety and authorization proof with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Feature/System/CustomerHealth/CustomerHealthAuthorizationTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Polish & Cross-Cutting Concerns
|
|
||||||
|
|
||||||
**Purpose**: Lock down vocabulary, formatting, and the minimal validation suite before implementation close-out.
|
|
||||||
|
|
||||||
- [x] T021 [P] Confirm that dashboard labels, dominant reason copy, health levels, the visible time-basis cue, and the no-duplicate-visible-summary rule stay aligned across `apps/platform/app/Support/CustomerHealth/CustomerHealthDimensionCatalog.php`, the dashboard widgets, and any linked platform-safe surfaces
|
|
||||||
- [x] T022 Run formatting on touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
|
||||||
- [x] T023 Run the full narrow validation suite with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth tests/Feature/System/CustomerHealth/CustomerHealthDashboardWidgetsTest.php tests/Feature/System/CustomerHealth/CustomerHealthExplainabilityTest.php tests/Feature/System/CustomerHealth/CustomerHealthAuthorizationTest.php`
|
|
||||||
- [x] T024 Review `apps/platform/app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php` and `apps/platform/resources/views/filament/system/widgets/customer-health-top-workspaces.blade.php` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/docs/product/standards/list-surface-review-checklist.md`, recording the accepted compact-widget exceptions before sign-off
|
|
||||||
|
|
||||||
Accepted compact-widget exceptions for sign-off: no persistence trio, no bulk actions, no row click, and no empty-state CTA because this is a read-only dashboard triage widget rather than a standalone Filament table surface.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7: Detail Follow-Up Decision Context
|
|
||||||
|
|
||||||
**Purpose**: Keep the dashboard-to-detail drilldown explainable by repeating the health decision above existing residual diagnostics.
|
|
||||||
|
|
||||||
- [x] T025 [US2] Preserve the selected dashboard time window on system health-detail drilldown links in `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php` so the linked residual detail pages can explain the same dominant drivers as the dashboard
|
|
||||||
- [x] T026 [US2] Rename the tenant/workspace drilldown affordance in `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php` from an opening verb to the decision-first `Review health details` copy while keeping `Open runs` unchanged for operational follow-up
|
|
||||||
- [x] T027 [US2] Add a read-only customer-health decision card above existing diagnostics on `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php`, `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php`, and their directory Blade views by reusing `WorkspaceHealthSummaryQuery`
|
|
||||||
- [x] T028 [P] [US2] Add focused feature coverage for the new residual detail follow-up card in `apps/platform/tests/Feature/System/CustomerHealth/CustomerHealthDetailDecisionTest.php`
|
|
||||||
- [x] T029 [US2] Run the focused drilldown validation proof with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Feature/System/CustomerHealth/CustomerHealthExplainabilityTest.php tests/Feature/System/CustomerHealth/CustomerHealthDetailDecisionTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies & Execution Order
|
|
||||||
|
|
||||||
### Recommended Execution Order
|
|
||||||
|
|
||||||
```text
|
|
||||||
Phase 1 (Setup)
|
|
||||||
↓
|
|
||||||
Phase 2 (Dimension catalog + derived summary query)
|
|
||||||
↙ ↘
|
|
||||||
US1 (aggregate health counts) US2 (attention-needed workspace list)
|
|
||||||
↓
|
|
||||||
US3 (honest unknown handling + authorization hardening)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Parallel Opportunities
|
|
||||||
|
|
||||||
- The foundational unit tests can be authored in parallel once the fixed first-slice dimension inventory is agreed.
|
|
||||||
- The aggregate summary widget and attention-needed widget feature tests can be written in parallel after the derived summary shape is stable.
|
|
||||||
- Authorization coverage can proceed in parallel with final unknown-handling hardening because it exercises existing `/system` gates.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Governance Checklist
|
|
||||||
|
|
||||||
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
|
||||||
- [x] New or changed tests stay in the smallest honest family, and no heavy-governance or browser family is introduced accidentally.
|
|
||||||
- [x] Shared helpers and fixture setup remain cheap by default.
|
|
||||||
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
|
|
||||||
- [x] The adopted surfaces explicitly use `standard-native-filament` relief.
|
|
||||||
- [x] No material budget or baseline escalation is introduced.
|
|
||||||
|
|
||||||
**Test-governance outcome (TEST-GOV-001)**: keep
|
|
||||||
Loading…
Reference in New Issue
Block a user