Compare commits
5 Commits
244-produc
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ff3392892b | |||
| e222845a36 | |||
| 6e3736a53f | |||
| 86505483bf | |||
| bf43e55848 |
@ -1,30 +1,34 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 2.9.0 -> 2.10.0
|
- Version change: 2.10.0 -> 2.11.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Expanded Operations / Run Observability Standard so OperationRun
|
- Expanded decision-first and operator-surface rules so operational,
|
||||||
start UX is shared-contract-owned instead of surface-owned
|
governance, evidence, onboarding, review, and support-facing
|
||||||
- Expanded Governance review expectations for OperationRun-starting
|
detail/status surfaces separate decision content, operator
|
||||||
features, explicit queued-notification policy, and bounded
|
diagnostics, and support/raw evidence
|
||||||
exceptions
|
- Expanded review and enforcement expectations so specs, plans,
|
||||||
|
tasks, and checklists must make audience modes, raw/support
|
||||||
|
gating, one dominant next action, and duplicate-truth prevention
|
||||||
|
explicit
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- OperationRun Start UX Contract (OPS-UX-START-001): centralizes
|
- Audience-Aware Decision Surfaces & Disclosure Ladder
|
||||||
queued toast/link/event/message semantics, run/artifact deep links,
|
(DECIDE-AUD-001): requires customer-readable default paths,
|
||||||
queued DB-notification policy, and tenant/workspace-safe operation
|
operator diagnostics as progressive disclosure, support/raw
|
||||||
URL resolution behind one shared OperationRun UX layer
|
evidence gating, one dominant next action, and no duplicate truth
|
||||||
|
across equal-priority cards
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- .specify/templates/spec-template.md: add OperationRun UX Impact
|
- .specify/templates/spec-template.md: add audience-aware disclosure
|
||||||
section + start-contract prompts ✅
|
section + constitution prompts ✅
|
||||||
- .specify/templates/plan-template.md: add OperationRun UX Impact
|
- .specify/templates/plan-template.md: add audience/disclosure
|
||||||
planning section + constitution checks ✅
|
planning prompts + constitution checks ✅
|
||||||
- .specify/templates/tasks-template.md: add central start-UX reuse,
|
- .specify/templates/tasks-template.md: add decision/disclosure
|
||||||
queued-notification policy, and exception tasks ✅
|
implementation + test tasks ✅
|
||||||
- .specify/templates/checklist-template.md: add OperationRun start
|
- .specify/templates/checklist-template.md: add disclosure, raw-gating,
|
||||||
UX review checks ✅
|
one-primary-action, and duplicate-truth review checks ✅
|
||||||
- docs/product/standards/README.md: refresh constitution index for
|
- docs/product/standards/README.md: refresh constitution index for
|
||||||
the new ops-UX contract ✅
|
the new audience-aware disclosure 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
|
||||||
@ -594,6 +598,109 @@ ##### Review gate
|
|||||||
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
|
||||||
@ -1317,11 +1424,22 @@ #### 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.
|
||||||
@ -1342,6 +1460,13 @@ #### 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)
|
||||||
|
|
||||||
@ -1366,8 +1491,11 @@ #### 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, whether any fake-native or host-drift risk is
|
restorable roles exist, which audience ladder and disclosure
|
||||||
present, and whether an exception type is used.
|
boundaries exist, what the dominant next action is, how raw/support
|
||||||
|
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
|
||||||
@ -1382,7 +1510,12 @@ #### 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, `Filament Costume`,
|
surfaces with no action/watch/reference hierarchy, duplicate visible
|
||||||
|
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.
|
||||||
@ -1394,11 +1527,15 @@ #### 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, absence of fake-native primary
|
stable canonical nouns across shells, presence of a single dominant
|
||||||
controls where metadata says the surface is native, bounded shared
|
next action where surface metadata exposes one, absence of duplicate
|
||||||
family contracts where metadata says a family is reused, explicit
|
visible decision summaries, explicit raw/support gating or secondary
|
||||||
state ownership where specs or metadata expose it, and dedicated
|
placement where the surface serves multiple audience classes,
|
||||||
tests for every approved exception.
|
absence of fake-native primary controls where metadata says the
|
||||||
|
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
|
||||||
|
|
||||||
@ -1465,6 +1602,10 @@ #### 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.
|
||||||
@ -1477,6 +1618,8 @@ #### 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.
|
||||||
@ -1486,6 +1629,8 @@ #### 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.
|
||||||
@ -1567,6 +1712,10 @@ ### 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
|
||||||
@ -1598,6 +1747,8 @@ ### 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.
|
||||||
@ -1611,7 +1762,9 @@ ### 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,
|
||||||
and MUST say what remains standardized.
|
MUST preserve dark mode correctness, spacing consistency,
|
||||||
|
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.
|
||||||
|
|
||||||
@ -1620,6 +1773,8 @@ ### 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.
|
||||||
@ -1658,6 +1813,11 @@ ### 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.
|
||||||
@ -1675,4 +1835,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.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-24
|
**Version**: 2.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-27
|
||||||
|
|||||||
@ -51,6 +51,14 @@ ## 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,6 +36,10 @@ ## 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]
|
||||||
@ -111,6 +115,10 @@ ## 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,
|
||||||
@ -120,6 +128,13 @@ ## 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,6 +89,17 @@ ## 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,
|
||||||
@ -254,6 +265,13 @@ ## 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,
|
||||||
@ -310,6 +328,7 @@ ## 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,
|
||||||
@ -367,6 +386,7 @@ ## 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,9 +78,21 @@ # 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,
|
||||||
@ -128,6 +140,12 @@ # 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),
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Entitlements;
|
||||||
|
|
||||||
|
final class WorkspaceEntitlementBlockedException extends \RuntimeException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $decision
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly array $decision)
|
||||||
|
{
|
||||||
|
parent::__construct((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks this action.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function decision(): array
|
||||||
|
{
|
||||||
|
return $this->decision;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
use App\Filament\Resources\OperationRunResource;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
@ -30,6 +31,7 @@
|
|||||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||||
|
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
@ -40,6 +42,10 @@
|
|||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas\Components\EmbeddedSchema;
|
use Filament\Schemas\Components\EmbeddedSchema;
|
||||||
@ -141,10 +147,6 @@ protected function getHeaderActions(): array
|
|||||||
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
||||||
: OperationRunLinks::index());
|
: OperationRunLinks::index());
|
||||||
|
|
||||||
if (isset($this->run)) {
|
|
||||||
$actions[] = $this->openSupportDiagnosticsAction();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! isset($this->run)) {
|
if (! isset($this->run)) {
|
||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
@ -167,6 +169,14 @@ protected function getHeaderActions(): array
|
|||||||
->color('gray');
|
->color('gray');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$actions[] = ActionGroup::make([
|
||||||
|
$this->openSupportDiagnosticsAction(),
|
||||||
|
$this->requestSupportAction(),
|
||||||
|
])
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-o-ellipsis-horizontal')
|
||||||
|
->color('gray');
|
||||||
|
|
||||||
$actions[] = $this->resumeCaptureAction();
|
$actions[] = $this->resumeCaptureAction();
|
||||||
|
|
||||||
return $actions;
|
return $actions;
|
||||||
@ -228,8 +238,6 @@ private function openSupportDiagnosticsAction(): Action
|
|||||||
$action = Action::make('openSupportDiagnostics')
|
$action = Action::make('openSupportDiagnostics')
|
||||||
->label('Open support diagnostics')
|
->label('Open support diagnostics')
|
||||||
->icon('heroicon-o-lifebuoy')
|
->icon('heroicon-o-lifebuoy')
|
||||||
->iconButton()
|
|
||||||
->tooltip('Open support diagnostics')
|
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->record($this->run)
|
->record($this->run)
|
||||||
->modal()
|
->modal()
|
||||||
@ -251,39 +259,85 @@ private function openSupportDiagnosticsAction(): Action
|
|||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function authorizeOperationRunSupportRequest(): void
|
||||||
|
{
|
||||||
|
$this->resolveRunTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requestSupportAction(): Action
|
||||||
|
{
|
||||||
|
$action = Action::make('requestSupport')
|
||||||
|
->label('Request support')
|
||||||
|
->icon('heroicon-o-paper-airplane')
|
||||||
|
->record($this->run)
|
||||||
|
->slideOver()
|
||||||
|
->stickyModalHeader()
|
||||||
|
->modalHeading('Request support')
|
||||||
|
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from the current run.')
|
||||||
|
->modalSubmitActionLabel('Submit support request')
|
||||||
|
->form([
|
||||||
|
Placeholder::make('primary_context')
|
||||||
|
->label('Primary context')
|
||||||
|
->content(fn (): string => OperationRunLinks::identifier($this->run))
|
||||||
|
->columnSpanFull(),
|
||||||
|
Placeholder::make('included_context')
|
||||||
|
->label('Included context')
|
||||||
|
->content(fn (): string => $this->operationSupportRequestAttachmentSummary())
|
||||||
|
->columnSpanFull(),
|
||||||
|
Select::make('severity')
|
||||||
|
->label('Severity')
|
||||||
|
->options(SupportRequest::severityOptions())
|
||||||
|
->default(SupportRequest::SEVERITY_NORMAL)
|
||||||
|
->required()
|
||||||
|
->native(false),
|
||||||
|
TextInput::make('summary')
|
||||||
|
->label('Summary')
|
||||||
|
->required()
|
||||||
|
->columnSpanFull(),
|
||||||
|
Textarea::make('reproduction_notes')
|
||||||
|
->label('Reproduction notes')
|
||||||
|
->rows(4)
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextInput::make('contact_name')
|
||||||
|
->label('Contact name')
|
||||||
|
->default(fn (): ?string => $this->resolveViewerActor()->name),
|
||||||
|
TextInput::make('contact_email')
|
||||||
|
->label('Contact email')
|
||||||
|
->email()
|
||||||
|
->default(fn (): ?string => $this->resolveViewerActor()->email),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$actor = $this->resolveViewerActor();
|
||||||
|
|
||||||
|
$supportRequest = app(SupportRequestSubmissionService::class)->submitForOperationRun($this->run, $actor, $data);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Support request submitted')
|
||||||
|
->body('Reference '.$supportRequest->internal_reference)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
|
||||||
|
return UiEnforcement::forAction($action)
|
||||||
|
->requireCapability(Capabilities::SUPPORT_REQUESTS_CREATE)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function operationRunSupportDiagnosticBundle(): array
|
public function operationRunSupportDiagnosticBundle(): array
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = $this->resolveViewerActor();
|
||||||
$tenant = $this->supportDiagnosticsTenant();
|
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->isMember($user, $tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(SupportDiagnosticBundleBuilder::class)->forOperationRun($this->run, $user);
|
return app(SupportDiagnosticBundleBuilder::class)->forOperationRun($this->run, $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function auditOperationSupportDiagnosticsOpen(): void
|
private function auditOperationSupportDiagnosticsOpen(): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = $this->resolveViewerActor();
|
||||||
$tenant = $this->supportDiagnosticsTenant();
|
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->recordSupportDiagnosticsOpened(
|
$this->recordSupportDiagnosticsOpened(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -307,6 +361,59 @@ private function supportDiagnosticsTenant(): ?Tenant
|
|||||||
return $this->run->loadMissing('tenant')->tenant;
|
return $this->run->loadMissing('tenant')->tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveViewerActor(): User
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveRunTenantForCapability(string $capability): Tenant
|
||||||
|
{
|
||||||
|
$tenant = $this->supportDiagnosticsTenant();
|
||||||
|
$user = $this->resolveViewerActor();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, $capability)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function operationSupportRequestAttachmentSummary(): string
|
||||||
|
{
|
||||||
|
$tenant = $this->supportDiagnosticsTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return 'Only canonical redacted run context will be attached.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return 'Only canonical redacted run context will be attached.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
|
||||||
|
? 'A redacted diagnostic snapshot and the canonical run context will be attached.'
|
||||||
|
: 'Only the canonical redacted run context will be attached because you cannot view support diagnostics.';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $bundle
|
* @param array<string, mixed> $bundle
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Services\ReviewPackService;
|
||||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
@ -176,6 +177,10 @@ public function table(Table $table): Table
|
|||||||
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
|
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
|
||||||
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
|
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
|
||||||
&& in_array($record->status, ['ready', 'published'], true))
|
&& in_array($record->status, ['ready', 'published'], true))
|
||||||
|
->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false))
|
||||||
|
->tooltip(fn (TenantReview $record): ?string => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false)
|
||||||
|
? (string) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['block_reason'] ?? '')
|
||||||
|
: null)
|
||||||
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
|
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
|
|||||||
@ -7,7 +7,11 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceSetting;
|
use App\Models\WorkspaceSetting;
|
||||||
|
use App\Support\Ai\AiPolicyMode;
|
||||||
|
use App\Support\Ai\AiUseCaseCatalog;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
|
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
||||||
use App\Services\Settings\SettingsResolver;
|
use App\Services\Settings\SettingsResolver;
|
||||||
use App\Services\Settings\SettingsWriter;
|
use App\Services\Settings\SettingsWriter;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -20,7 +24,9 @@
|
|||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\KeyValue;
|
use Filament\Forms\Components\KeyValue;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
@ -51,6 +57,7 @@ class WorkspaceSettings extends Page
|
|||||||
* @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}>
|
* @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}>
|
||||||
*/
|
*/
|
||||||
private const SETTING_FIELDS = [
|
private const SETTING_FIELDS = [
|
||||||
|
'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'],
|
||||||
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
|
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
|
||||||
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
|
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
|
||||||
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
|
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
|
||||||
@ -58,10 +65,23 @@ class WorkspaceSettings extends Page
|
|||||||
'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'],
|
'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'],
|
||||||
'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'],
|
'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'],
|
||||||
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
|
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
|
||||||
|
'entitlements_plan_profile' => ['domain' => 'entitlements', 'key' => 'plan_profile', 'type' => 'string'],
|
||||||
|
'entitlements_managed_tenant_limit_override_value' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_value', 'type' => 'int'],
|
||||||
|
'entitlements_managed_tenant_limit_override_reason' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_reason', 'type' => 'string'],
|
||||||
|
'entitlements_review_pack_generation_override_value' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_value', 'type' => 'bool'],
|
||||||
|
'entitlements_review_pack_generation_override_reason' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_reason', 'type' => 'string'],
|
||||||
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
|
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
|
||||||
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
|
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private const ENTITLEMENT_OVERRIDE_REASON_FIELDS = [
|
||||||
|
'entitlements_managed_tenant_limit_override_value' => 'entitlements_managed_tenant_limit_override_reason',
|
||||||
|
'entitlements_review_pack_generation_override_value' => 'entitlements_review_pack_generation_override_reason',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fields rendered as Filament KeyValue components (array state, not JSON string).
|
* Fields rendered as Filament KeyValue components (array state, not JSON string).
|
||||||
*
|
*
|
||||||
@ -111,6 +131,14 @@ class WorkspaceSettings extends Page
|
|||||||
*/
|
*/
|
||||||
public array $resolvedSettings = [];
|
public array $resolvedSettings = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array{
|
||||||
|
* plan_profile?: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
|
||||||
|
* decisions?: array<string, array<string, mixed>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public array $entitlementSummary = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
|
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
|
||||||
*
|
*
|
||||||
@ -180,6 +208,71 @@ public function content(Schema $schema): Schema
|
|||||||
return $schema
|
return $schema
|
||||||
->statePath('data')
|
->statePath('data')
|
||||||
->schema([
|
->schema([
|
||||||
|
Section::make('Workspace entitlements')
|
||||||
|
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Select::make('entitlements_plan_profile')
|
||||||
|
->label('Plan profile')
|
||||||
|
->options(app(WorkspacePlanProfileCatalog::class)->optionLabels())
|
||||||
|
->placeholder(sprintf('Use default profile (%s)', app(WorkspacePlanProfileCatalog::class)->default()['label']))
|
||||||
|
->native(false)
|
||||||
|
->columnSpanFull()
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->planProfileFieldHelperText()),
|
||||||
|
TextInput::make('entitlements_managed_tenant_limit_override_value')
|
||||||
|
->label('Managed tenant activation limit override')
|
||||||
|
->placeholder('Unset (uses plan profile default)')
|
||||||
|
->suffix('tenants')
|
||||||
|
->hint('0 or greater')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(0)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->managedTenantLimitHelperText())
|
||||||
|
->hintAction($this->makeResetAction('entitlements_managed_tenant_limit_override_value')),
|
||||||
|
Textarea::make('entitlements_managed_tenant_limit_override_reason')
|
||||||
|
->label('Managed tenant activation override reason')
|
||||||
|
->rows(3)
|
||||||
|
->maxLength(500)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->managedTenantLimitReasonHelperText()),
|
||||||
|
Select::make('entitlements_review_pack_generation_override_value')
|
||||||
|
->label('Review pack generation override')
|
||||||
|
->options(self::booleanOptions())
|
||||||
|
->placeholder('Unset (uses plan profile default)')
|
||||||
|
->native(false)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->reviewPackGenerationHelperText())
|
||||||
|
->hintAction($this->makeResetAction('entitlements_review_pack_generation_override_value')),
|
||||||
|
Textarea::make('entitlements_review_pack_generation_override_reason')
|
||||||
|
->label('Review pack generation override reason')
|
||||||
|
->rows(3)
|
||||||
|
->maxLength(500)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->reviewPackGenerationReasonHelperText()),
|
||||||
|
]),
|
||||||
|
Section::make('Workspace AI policy')
|
||||||
|
->description($this->sectionDescription('ai', 'Control whether the workspace disables AI entirely or allows approved internal-only drafts on private-only infrastructure.'))
|
||||||
|
->schema([
|
||||||
|
Select::make('ai_policy_mode')
|
||||||
|
->label('AI posture')
|
||||||
|
->options(AiPolicyMode::optionLabels())
|
||||||
|
->placeholder('Unset (uses default)')
|
||||||
|
->native(false)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->aiPolicyModeHelperText())
|
||||||
|
->hintAction($this->makeResetAction('ai_policy_mode')),
|
||||||
|
Placeholder::make('ai_approved_use_cases')
|
||||||
|
->label('Approved use cases')
|
||||||
|
->content(fn (): string => $this->aiApprovedUseCasesText()),
|
||||||
|
Placeholder::make('ai_allowed_provider_classes')
|
||||||
|
->label('Allowed provider classes')
|
||||||
|
->content(fn (): string => $this->aiAllowedProviderClassesText()),
|
||||||
|
Placeholder::make('ai_blocked_data_classifications')
|
||||||
|
->label('Blocked data classifications')
|
||||||
|
->content(fn (): string => $this->aiBlockedDataClassificationsText()),
|
||||||
|
]),
|
||||||
Section::make('Backup settings')
|
Section::make('Backup settings')
|
||||||
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
|
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
|
||||||
->schema([
|
->schema([
|
||||||
@ -455,6 +548,56 @@ public function resetSetting(string $field): void
|
|||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resetEntitlementOverridePair(string $field): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorizeWorkspaceManage($user);
|
||||||
|
|
||||||
|
if (! $this->hasEntitlementOverridePair($field)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Entitlement already uses plan profile default')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$writer = app(SettingsWriter::class);
|
||||||
|
$valueSetting = $this->settingForField($field);
|
||||||
|
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
|
||||||
|
$reasonSetting = $this->settingForField($reasonField);
|
||||||
|
|
||||||
|
if ($this->workspaceOverrideForField($field) !== null) {
|
||||||
|
$writer->resetWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $this->workspace,
|
||||||
|
domain: $valueSetting['domain'],
|
||||||
|
key: $valueSetting['key'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->workspaceOverrideForField($reasonField) !== null) {
|
||||||
|
$writer->resetWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $this->workspace,
|
||||||
|
domain: $reasonSetting['domain'],
|
||||||
|
key: $reasonSetting['key'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadFormState();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Workspace entitlement override reset')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
private function loadFormState(): void
|
private function loadFormState(): void
|
||||||
{
|
{
|
||||||
$resolver = app(SettingsResolver::class);
|
$resolver = app(SettingsResolver::class);
|
||||||
@ -490,6 +633,7 @@ private function loadFormState(): void
|
|||||||
$this->data = $data;
|
$this->data = $data;
|
||||||
$this->workspaceOverrides = $workspaceOverrides;
|
$this->workspaceOverrides = $workspaceOverrides;
|
||||||
$this->resolvedSettings = $resolvedSettings;
|
$this->resolvedSettings = $resolvedSettings;
|
||||||
|
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
|
||||||
|
|
||||||
$this->loadDomainLastModified();
|
$this->loadDomainLastModified();
|
||||||
}
|
}
|
||||||
@ -563,15 +707,25 @@ private function makeResetAction(string $field): Action
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function () use ($field): void {
|
->action(function () use ($field): void {
|
||||||
|
if ($this->isEntitlementOverrideValueField($field)) {
|
||||||
|
$this->resetEntitlementOverridePair($field);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->resetSetting($field);
|
$this->resetSetting($field);
|
||||||
})
|
})
|
||||||
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field))
|
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field))
|
||||||
->tooltip(function () use ($field): ?string {
|
->tooltip(function () use ($field): ?string {
|
||||||
if (! $this->currentUserCanManage()) {
|
if (! $this->currentUserCanManage()) {
|
||||||
return 'You do not have permission to manage workspace settings.';
|
return 'You do not have permission to manage workspace settings.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->hasWorkspaceOverride($field)) {
|
if (! $this->canResetField($field)) {
|
||||||
|
if ($this->isEntitlementOverrideValueField($field)) {
|
||||||
|
return 'No workspace override to reset.';
|
||||||
|
}
|
||||||
|
|
||||||
return 'No workspace override to reset.';
|
return 'No workspace override to reset.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -579,6 +733,200 @@ private function makeResetAction(string $field): Action
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function canResetField(string $field): bool
|
||||||
|
{
|
||||||
|
if ($this->isEntitlementOverrideValueField($field)) {
|
||||||
|
return $this->hasEntitlementOverridePair($field);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->hasWorkspaceOverride($field);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEntitlementOverrideValueField(string $field): bool
|
||||||
|
{
|
||||||
|
return array_key_exists($field, self::ENTITLEMENT_OVERRIDE_REASON_FIELDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasEntitlementOverridePair(string $field): bool
|
||||||
|
{
|
||||||
|
if (! $this->isEntitlementOverrideValueField($field)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
|
||||||
|
|
||||||
|
return $this->workspaceOverrideForField($field) !== null
|
||||||
|
|| $this->workspaceOverrideForField($reasonField) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function planProfileFieldHelperText(): string
|
||||||
|
{
|
||||||
|
$profile = $this->resolvedPlanProfile();
|
||||||
|
$selectedProfile = $this->workspaceOverrideForField('entitlements_plan_profile');
|
||||||
|
|
||||||
|
if (! is_string($selectedProfile) || $selectedProfile === '') {
|
||||||
|
return sprintf('Default profile: %s. %s', $profile['label'], $profile['description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('Effective profile: %s. %s', $profile['label'], $profile['description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function managedTenantLimitHelperText(): string
|
||||||
|
{
|
||||||
|
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
|
||||||
|
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
|
||||||
|
$currentUsage = (int) ($decision['current_usage'] ?? 0);
|
||||||
|
$remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
|
||||||
|
|
||||||
|
$capacityText = $remainingCapacity < 0
|
||||||
|
? sprintf('Over limit by %d.', abs($remainingCapacity))
|
||||||
|
: sprintf('%d remaining.', $remainingCapacity);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Effective limit: %d active managed tenants. Current usage: %d. %s Source: %s.',
|
||||||
|
$effectiveValue,
|
||||||
|
$currentUsage,
|
||||||
|
$capacityText,
|
||||||
|
$this->entitlementSourceLabel($decision),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function managedTenantLimitReasonHelperText(): string
|
||||||
|
{
|
||||||
|
return $this->entitlementReasonHelperText(
|
||||||
|
valueField: 'entitlements_managed_tenant_limit_override_value',
|
||||||
|
key: WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewPackGenerationHelperText(): string
|
||||||
|
{
|
||||||
|
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Effective state: %s. Source: %s.',
|
||||||
|
(bool) ($decision['effective_value'] ?? false) ? 'enabled' : 'disabled',
|
||||||
|
$this->entitlementSourceLabel($decision),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewPackGenerationReasonHelperText(): string
|
||||||
|
{
|
||||||
|
return $this->entitlementReasonHelperText(
|
||||||
|
valueField: 'entitlements_review_pack_generation_override_value',
|
||||||
|
key: WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function aiPolicyModeHelperText(): string
|
||||||
|
{
|
||||||
|
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($resolved)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$mode = AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
|
||||||
|
?? AiPolicyMode::Disabled;
|
||||||
|
|
||||||
|
$prefix = ! $this->hasWorkspaceOverride('ai_policy_mode')
|
||||||
|
? sprintf('Effective posture: %s. Source: %s.', $mode->label(), $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')))
|
||||||
|
: sprintf('Effective posture: %s.', $mode->label());
|
||||||
|
|
||||||
|
return sprintf('%s %s', $prefix, $mode->summary());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function aiApprovedUseCasesText(): string
|
||||||
|
{
|
||||||
|
return implode('; ', app(AiUseCaseCatalog::class)->labels()).'.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function aiAllowedProviderClassesText(): string
|
||||||
|
{
|
||||||
|
$labels = app(AiUseCaseCatalog::class)->allowedProviderClassLabelsForMode($this->effectiveAiPolicyMode());
|
||||||
|
|
||||||
|
if ($labels === []) {
|
||||||
|
return 'No provider classes are allowed while AI is disabled.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', $labels).'.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function aiBlockedDataClassificationsText(): string
|
||||||
|
{
|
||||||
|
return implode(', ', app(AiUseCaseCatalog::class)->blockedDataClassificationLabels()).'.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function effectiveAiPolicyMode(): AiPolicyMode
|
||||||
|
{
|
||||||
|
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($resolved)) {
|
||||||
|
return AiPolicyMode::Disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
|
||||||
|
?? AiPolicyMode::Disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function entitlementReasonHelperText(string $valueField, string $key): string
|
||||||
|
{
|
||||||
|
$decision = $this->entitlementDecision($key);
|
||||||
|
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
|
||||||
|
|
||||||
|
if ($this->workspaceOverrideForField($valueField) === null) {
|
||||||
|
return 'Required when an explicit override value is set.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rationale === null || $rationale === '') {
|
||||||
|
return 'Required when an explicit override value is set.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('Current rationale: %s', $rationale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
||||||
|
*/
|
||||||
|
private function resolvedPlanProfile(): array
|
||||||
|
{
|
||||||
|
$profile = $this->entitlementSummary['plan_profile'] ?? null;
|
||||||
|
|
||||||
|
if (is_array($profile)) {
|
||||||
|
return $profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(WorkspacePlanProfileCatalog::class)->default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function entitlementDecision(string $key): array
|
||||||
|
{
|
||||||
|
$decision = $this->entitlementSummary['decisions'][$key] ?? null;
|
||||||
|
|
||||||
|
return is_array($decision) ? $decision : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $decision
|
||||||
|
*/
|
||||||
|
private function entitlementSourceLabel(array $decision): string
|
||||||
|
{
|
||||||
|
if (($decision['source'] ?? null) === 'workspace_override') {
|
||||||
|
return 'workspace override';
|
||||||
|
}
|
||||||
|
|
||||||
|
$planProfileLabel = $decision['plan_profile_label'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($planProfileLabel) && $planProfileLabel !== '') {
|
||||||
|
return sprintf('%s plan profile', $planProfileLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'plan profile default';
|
||||||
|
}
|
||||||
|
|
||||||
private function helperTextFor(string $field): string
|
private function helperTextFor(string $field): string
|
||||||
{
|
{
|
||||||
$resolved = $this->resolvedSettings[$field] ?? null;
|
$resolved = $this->resolvedSettings[$field] ?? null;
|
||||||
@ -721,6 +1069,27 @@ private function normalizedInputValues(): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (self::ENTITLEMENT_OVERRIDE_REASON_FIELDS as $valueField => $reasonField) {
|
||||||
|
if (($normalizedValues[$valueField] ?? null) === null) {
|
||||||
|
$normalizedValues[$reasonField] = null;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($normalizedValues[$reasonField] ?? null) !== null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = match ($valueField) {
|
||||||
|
'entitlements_managed_tenant_limit_override_value' => 'Override reason is required when a managed tenant activation limit override is set.',
|
||||||
|
'entitlements_review_pack_generation_override_value' => 'Override reason is required when a review pack generation override is set.',
|
||||||
|
default => 'Override reason is required when an explicit override is set.',
|
||||||
|
};
|
||||||
|
|
||||||
|
$validationErrors['data.'.$reasonField] ??= [];
|
||||||
|
$validationErrors['data.'.$reasonField][] = $message;
|
||||||
|
}
|
||||||
|
|
||||||
return [$normalizedValues, $validationErrors];
|
return [$normalizedValues, $validationErrors];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
||||||
use App\Filament\Widgets\Dashboard\RecentOperations;
|
use App\Filament\Widgets\Dashboard\RecentOperations;
|
||||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
@ -20,8 +21,14 @@
|
|||||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||||
|
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Dashboard;
|
use Filament\Pages\Dashboard;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
use Filament\Widgets\WidgetConfiguration;
|
use Filament\Widgets\WidgetConfiguration;
|
||||||
@ -70,10 +77,72 @@ public function getColumns(): int|array
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
$this->requestSupportAction(),
|
||||||
$this->openSupportDiagnosticsAction(),
|
$this->openSupportDiagnosticsAction(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function authorizeTenantSupportRequest(): void
|
||||||
|
{
|
||||||
|
$this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requestSupportAction(): Action
|
||||||
|
{
|
||||||
|
$action = Action::make('requestSupport')
|
||||||
|
->label('Request support')
|
||||||
|
->icon('heroicon-o-paper-airplane')
|
||||||
|
->color('gray')
|
||||||
|
->slideOver()
|
||||||
|
->stickyModalHeader()
|
||||||
|
->modalHeading('Request support')
|
||||||
|
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from existing records.')
|
||||||
|
->modalSubmitActionLabel('Submit request')
|
||||||
|
->form([
|
||||||
|
Placeholder::make('included_context')
|
||||||
|
->label('Included context')
|
||||||
|
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
|
||||||
|
->columnSpanFull(),
|
||||||
|
Select::make('severity')
|
||||||
|
->label('Severity')
|
||||||
|
->options(SupportRequest::severityOptions())
|
||||||
|
->default(SupportRequest::SEVERITY_NORMAL)
|
||||||
|
->required()
|
||||||
|
->native(false),
|
||||||
|
TextInput::make('summary')
|
||||||
|
->label('Summary')
|
||||||
|
->required()
|
||||||
|
->columnSpanFull(),
|
||||||
|
Textarea::make('reproduction_notes')
|
||||||
|
->label('Reproduction notes')
|
||||||
|
->rows(4)
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextInput::make('contact_name')
|
||||||
|
->label('Contact name')
|
||||||
|
->default(fn (): ?string => $this->resolveDashboardActor()->name),
|
||||||
|
TextInput::make('contact_email')
|
||||||
|
->label('Contact email')
|
||||||
|
->email()
|
||||||
|
->default(fn (): ?string => $this->resolveDashboardActor()->email),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$actor = $this->resolveDashboardActor();
|
||||||
|
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
|
||||||
|
|
||||||
|
$supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Support request submitted')
|
||||||
|
->body('Reference '.$supportRequest->internal_reference)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
|
||||||
|
return UiEnforcement::forAction($action)
|
||||||
|
->requireCapability(Capabilities::SUPPORT_REQUESTS_CREATE)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
private function openSupportDiagnosticsAction(): Action
|
private function openSupportDiagnosticsAction(): Action
|
||||||
{
|
{
|
||||||
$action = Action::make('openSupportDiagnostics')
|
$action = Action::make('openSupportDiagnostics')
|
||||||
@ -104,34 +173,16 @@ private function openSupportDiagnosticsAction(): Action
|
|||||||
*/
|
*/
|
||||||
public function tenantSupportDiagnosticBundle(): array
|
public function tenantSupportDiagnosticBundle(): array
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = $this->resolveDashboardActor();
|
||||||
$tenant = Filament::getTenant();
|
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->isMember($user, $tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(SupportDiagnosticBundleBuilder::class)->forTenant($tenant, $user);
|
return app(SupportDiagnosticBundleBuilder::class)->forTenant($tenant, $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function auditTenantSupportDiagnosticsOpen(): void
|
private function auditTenantSupportDiagnosticsOpen(): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = $this->resolveDashboardActor();
|
||||||
$tenant = Filament::getTenant();
|
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->recordSupportDiagnosticsOpened(
|
$this->recordSupportDiagnosticsOpened(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -172,4 +223,57 @@ private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, U
|
|||||||
|
|
||||||
$this->supportDiagnosticsAuditKeys[] = $auditKey;
|
$this->supportDiagnosticsAuditKeys[] = $auditKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveDashboardActor(): User
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveCurrentTenantForCapability(string $capability): Tenant
|
||||||
|
{
|
||||||
|
$user = $this->resolveDashboardActor();
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, $capability)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenantSupportRequestAttachmentSummary(): string
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return 'Only canonical redacted tenant context will be attached.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return 'Only canonical redacted tenant context will be attached.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
|
||||||
|
? 'A redacted diagnostic snapshot and the canonical tenant context will be attached.'
|
||||||
|
: 'Only the canonical redacted tenant context will be attached because you cannot view support diagnostics.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -29,6 +30,7 @@
|
|||||||
use App\Services\Onboarding\OnboardingDraftResolver;
|
use App\Services\Onboarding\OnboardingDraftResolver;
|
||||||
use App\Services\Onboarding\OnboardingDraftStageResolver;
|
use App\Services\Onboarding\OnboardingDraftStageResolver;
|
||||||
use App\Services\Onboarding\OnboardingLifecycleService;
|
use App\Services\Onboarding\OnboardingLifecycleService;
|
||||||
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Providers\ProviderConnectionMutationService;
|
use App\Services\Providers\ProviderConnectionMutationService;
|
||||||
use App\Services\Providers\ProviderOperationRegistry;
|
use App\Services\Providers\ProviderOperationRegistry;
|
||||||
@ -51,6 +53,7 @@
|
|||||||
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;
|
||||||
@ -660,7 +663,16 @@ public function content(Schema $schema): Schema
|
|||||||
Text::make(fn (): string => $this->completionSummaryBootstrapSummary())
|
Text::make(fn (): string => $this->completionSummaryBootstrapSummary())
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (): string => $this->completionSummaryBootstrapColor()),
|
->color(fn (): string => $this->completionSummaryBootstrapColor()),
|
||||||
|
Text::make('Activation entitlement')
|
||||||
|
->color('gray'),
|
||||||
|
Text::make(fn (): string => $this->completionSummaryEntitlementSummary())
|
||||||
|
->badge()
|
||||||
|
->color(fn (): string => $this->completionSummaryEntitlementColor()),
|
||||||
]),
|
]),
|
||||||
|
Callout::make('Activation entitlement')
|
||||||
|
->description(fn (): string => $this->completionSummaryEntitlementDetail())
|
||||||
|
->warning()
|
||||||
|
->visible(fn (): bool => $this->completionSummaryEntitlementBlocked()),
|
||||||
Callout::make('Bootstrap needs attention')
|
Callout::make('Bootstrap needs attention')
|
||||||
->description(fn (): string => $this->completionSummaryBootstrapRecoveryMessage())
|
->description(fn (): string => $this->completionSummaryBootstrapRecoveryMessage())
|
||||||
->warning()
|
->warning()
|
||||||
@ -698,9 +710,7 @@ public function content(Schema $schema): Schema
|
|||||||
->modalSubmitActionLabel('Yes, complete onboarding')
|
->modalSubmitActionLabel('Yes, complete onboarding')
|
||||||
->disabled(fn (): bool => ! $this->canCompleteOnboarding()
|
->disabled(fn (): bool => ! $this->canCompleteOnboarding()
|
||||||
|| ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE))
|
|| ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE))
|
||||||
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)
|
->tooltip(fn (): ?string => $this->completionActionTooltip())
|
||||||
? null
|
|
||||||
: 'Owner required to complete onboarding.')
|
|
||||||
->action(fn () => $this->completeOnboarding()),
|
->action(fn () => $this->completeOnboarding()),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
@ -994,6 +1004,7 @@ 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')
|
||||||
@ -1001,7 +1012,7 @@ private function routeBoundReadinessSchema(): array
|
|||||||
->compact()
|
->compact()
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->schema([
|
->schema([
|
||||||
Text::make('Current checkpoint')
|
Text::make('Step')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
Text::make($payload['checkpoint']['current_checkpoint_label'] ?? '—')
|
Text::make($payload['checkpoint']['current_checkpoint_label'] ?? '—')
|
||||||
->badge()
|
->badge()
|
||||||
@ -1021,9 +1032,7 @@ 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'),
|
||||||
Text::make($payload['next_action']['label'])
|
$primaryNextAction,
|
||||||
->badge()
|
|
||||||
->color($this->readinessNextActionColor($payload['next_action']['kind'])),
|
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -1064,8 +1073,14 @@ 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 === []) {
|
if ($links === [] && ! ($showAssist && $requiredPermissionsUrl !== null && $requiredPermissionsUrl !== '')) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1089,13 +1104,20 @@ 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 when deeper diagnostics are needed.')
|
->description('Open canonical operation detail or secondary permission evidence 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'),
|
||||||
@ -1115,14 +1137,16 @@ 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 && ! $isVisible) {
|
if ($missingApplication + $missingDelegated + $errors === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1177,7 +1201,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, 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, has_report: 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},
|
||||||
@ -1218,6 +1242,9 @@ 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,
|
||||||
@ -1237,6 +1264,9 @@ 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(),
|
||||||
@ -1263,6 +1293,7 @@ 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)
|
||||||
@ -1286,8 +1317,8 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
'blocker' => [
|
'blocker' => [
|
||||||
'reason_code' => is_string($snapshot['reason_code'] ?? null) ? $snapshot['reason_code'] : null,
|
'reason_code' => $reasonCode,
|
||||||
'blocking_reason_code' => is_string($snapshot['blocking_reason_code'] ?? null) ? $snapshot['blocking_reason_code'] : null,
|
'blocking_reason_code' => $blockingReasonCode,
|
||||||
'operator_summary' => $readinessSummary,
|
'operator_summary' => $readinessSummary,
|
||||||
],
|
],
|
||||||
'next_action' => $this->readinessNextAction(
|
'next_action' => $this->readinessNextAction(
|
||||||
@ -1297,6 +1328,7 @@ 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,
|
||||||
@ -1374,6 +1406,35 @@ 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 : [];
|
||||||
@ -1407,8 +1468,8 @@ private function readinessProviderSummary(?ProviderConnection $connection): ?arr
|
|||||||
return [
|
return [
|
||||||
'provider' => (string) $connection->provider,
|
'provider' => (string) $connection->provider,
|
||||||
'target_scope' => [],
|
'target_scope' => [],
|
||||||
'consent_state' => (string) $connection->consent_status,
|
'consent_state' => $this->stringValue($connection->consent_status),
|
||||||
'verification_state' => (string) $connection->verification_status,
|
'verification_state' => $this->stringValue($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,
|
||||||
@ -1614,6 +1675,7 @@ 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,
|
||||||
@ -1639,7 +1701,7 @@ private function readinessNextAction(
|
|||||||
|
|
||||||
if ($consentState !== ProviderConsentStatus::Granted->value) {
|
if ($consentState !== ProviderConsentStatus::Granted->value) {
|
||||||
return $this->readinessAction(
|
return $this->readinessAction(
|
||||||
label: 'Grant consent',
|
label: 'Grant admin 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,
|
||||||
);
|
);
|
||||||
@ -1647,6 +1709,18 @@ 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',
|
||||||
@ -2777,6 +2851,7 @@ private function verificationReportViewData(): array
|
|||||||
'acknowledgements' => [],
|
'acknowledgements' => [],
|
||||||
'surface' => [],
|
'surface' => [],
|
||||||
'redactionNotes' => [],
|
'redactionNotes' => [],
|
||||||
|
'contextualHelp' => null,
|
||||||
'assistVisibility' => $assistVisibility,
|
'assistVisibility' => $assistVisibility,
|
||||||
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
||||||
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
||||||
@ -2786,6 +2861,7 @@ 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);
|
||||||
@ -2872,6 +2948,7 @@ 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',
|
||||||
@ -2879,6 +2956,40 @@ 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')
|
||||||
@ -4395,6 +4506,10 @@ private function canCompleteOnboarding(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->completionSummaryEntitlementBlocked()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$user = $this->currentUser();
|
$user = $this->currentUser();
|
||||||
|
|
||||||
if (! app(TenantOperabilityService::class)->outcomeFor(
|
if (! app(TenantOperabilityService::class)->outcomeFor(
|
||||||
@ -4427,6 +4542,111 @@ private function canCompleteOnboarding(): bool
|
|||||||
return trim((string) ($this->data['override_reason'] ?? '')) !== '';
|
return trim((string) ($this->data['override_reason'] ?? '')) !== '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function completionSummaryEntitlementDecision(): array
|
||||||
|
{
|
||||||
|
if (! isset($this->workspace) || ! $this->workspace instanceof Workspace) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(WorkspaceEntitlementResolver::class)->resolve(
|
||||||
|
$this->workspace,
|
||||||
|
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completionSummaryEntitlementBlocked(): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->completionSummaryEntitlementDecision()['is_blocked'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completionSummaryEntitlementSummary(): string
|
||||||
|
{
|
||||||
|
$decision = $this->completionSummaryEntitlementDecision();
|
||||||
|
$currentUsage = (int) ($decision['current_usage'] ?? 0);
|
||||||
|
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
|
||||||
|
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'%s - %d active of %d allowed (%s)',
|
||||||
|
$this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed',
|
||||||
|
$currentUsage,
|
||||||
|
$effectiveValue,
|
||||||
|
$sourceLabel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completionSummaryEntitlementDetail(): string
|
||||||
|
{
|
||||||
|
$decision = $this->completionSummaryEntitlementDecision();
|
||||||
|
$currentUsage = (int) ($decision['current_usage'] ?? 0);
|
||||||
|
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
|
||||||
|
$remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
|
||||||
|
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision);
|
||||||
|
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
|
||||||
|
$message = sprintf(
|
||||||
|
'Current usage is %d active managed tenant%s out of %d allowed. Source: %s.',
|
||||||
|
$currentUsage,
|
||||||
|
$currentUsage === 1 ? '' : 's',
|
||||||
|
$effectiveValue,
|
||||||
|
$sourceLabel,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($remainingCapacity >= 0) {
|
||||||
|
$message .= sprintf(' Remaining capacity: %d.', $remainingCapacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->completionSummaryEntitlementBlocked()) {
|
||||||
|
$blockReason = is_string($decision['block_reason'] ?? null) ? $decision['block_reason'] : null;
|
||||||
|
|
||||||
|
if ($blockReason !== null && $blockReason !== '') {
|
||||||
|
$message = $blockReason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === 'workspace_override') {
|
||||||
|
$message .= ' Rationale: '.$rationale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completionSummaryEntitlementColor(): string
|
||||||
|
{
|
||||||
|
return $this->completionSummaryEntitlementBlocked() ? 'warning' : 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $decision
|
||||||
|
*/
|
||||||
|
private function completionSummaryEntitlementSourceLabel(array $decision): string
|
||||||
|
{
|
||||||
|
if (($decision['source'] ?? null) === 'workspace_override') {
|
||||||
|
return 'workspace override';
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $decision['plan_profile_label'] ?? null;
|
||||||
|
|
||||||
|
return is_string($label) && $label !== ''
|
||||||
|
? sprintf('%s plan profile', $label)
|
||||||
|
: 'plan profile default';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completionActionTooltip(): ?string
|
||||||
|
{
|
||||||
|
if (! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)) {
|
||||||
|
return 'Owner required to complete onboarding.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->completionSummaryEntitlementBlocked()) {
|
||||||
|
return $this->completionSummaryEntitlementDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private function completionSummaryTenantLine(): string
|
private function completionSummaryTenantLine(): string
|
||||||
{
|
{
|
||||||
$tenant = $this->currentManagedTenantRecord();
|
$tenant = $this->currentManagedTenantRecord();
|
||||||
@ -4507,6 +4727,19 @@ 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();
|
||||||
@ -4747,6 +4980,16 @@ public function completeOnboarding(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->completionSummaryEntitlementBlocked()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Activation limit reached')
|
||||||
|
->body($this->completionSummaryEntitlementDetail())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$run = $this->verificationRun();
|
$run = $this->verificationRun();
|
||||||
$verificationSucceeded = $this->verificationHasSucceeded();
|
$verificationSucceeded = $this->verificationHasSucceeded();
|
||||||
$verificationCanProceed = $this->verificationCanProceed();
|
$verificationCanProceed = $this->verificationCanProceed();
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
|
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
|
||||||
use App\Filament\Resources\ReviewPackResource\Pages;
|
use App\Filament\Resources\ReviewPackResource\Pages;
|
||||||
@ -10,6 +12,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
@ -45,6 +48,8 @@
|
|||||||
|
|
||||||
class ReviewPackResource extends Resource
|
class ReviewPackResource extends Resource
|
||||||
{
|
{
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static ?string $model = ReviewPack::class;
|
protected static ?string $model = ReviewPack::class;
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
@ -102,9 +107,9 @@ public static function canView(Model $record): bool
|
|||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action appears in the list header once review packs exist.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state carries the single Generate CTA while the registry is empty.')
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download remains the only direct row shortcut and Expire is grouped under More.')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download remains the only direct row shortcut and Expire is grouped under More.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
||||||
@ -350,14 +355,37 @@ public static function table(Table $table): Table
|
|||||||
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
|
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
|
||||||
->emptyStateIcon('heroicon-o-document-arrow-down')
|
->emptyStateIcon('heroicon-o-document-arrow-down')
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
UiEnforcement::forAction(
|
static::generatePackAction(name: 'generate_first', label: 'Generate first pack'),
|
||||||
Actions\Action::make('generate_first')
|
]);
|
||||||
->label('Generate first pack')
|
}
|
||||||
|
|
||||||
|
public static function generatePackAction(string $name = 'generate_pack', string $label = 'Generate Pack'): Actions\Action
|
||||||
|
{
|
||||||
|
$action = UiEnforcement::forAction(
|
||||||
|
Actions\Action::make($name)
|
||||||
|
->label($label)
|
||||||
->icon('heroicon-o-plus')
|
->icon('heroicon-o-plus')
|
||||||
|
->disabled(fn (): bool => static::reviewPackGenerationBlocked())
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
static::executeGeneration($data);
|
static::executeGeneration($data);
|
||||||
})
|
})
|
||||||
->form([
|
->form(static::reviewPackGenerationFormSchema())
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||||
|
->preserveDisabled()
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
$action->tooltip(fn (): ?string => static::reviewPackGenerationActionTooltip());
|
||||||
|
|
||||||
|
return $action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Section>
|
||||||
|
*/
|
||||||
|
public static function reviewPackGenerationFormSchema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
Section::make('Pack options')
|
Section::make('Pack options')
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('include_pii')
|
Toggle::make('include_pii')
|
||||||
@ -369,22 +397,20 @@ public static function table(Table $table): Table
|
|||||||
->helperText('Include recent operation history in the export.')
|
->helperText('Include recent operation history in the export.')
|
||||||
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
||||||
]),
|
]),
|
||||||
])
|
];
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenant = Filament::getTenant();
|
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
|
return parent::getEloquentQuery()
|
||||||
|
->with(['tenant', 'operationRun', 'evidenceSnapshot', 'tenantReview'])
|
||||||
|
->where('tenant_id', (int) $tenant->getKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
@ -458,6 +484,14 @@ public static function executeGeneration(array $data): void
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$reviewPack = $service->generate($tenant, $user, $options);
|
$reviewPack = $service->generate($tenant, $user, $options);
|
||||||
|
} catch (WorkspaceEntitlementBlockedException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->warning()
|
||||||
|
->title('Review pack generation unavailable')
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
} catch (ReviewPackEvidenceResolutionException $exception) {
|
} catch (ReviewPackEvidenceResolutionException $exception) {
|
||||||
$reasons = $exception->result->reasons;
|
$reasons = $exception->result->reasons;
|
||||||
|
|
||||||
@ -493,4 +527,55 @@ public static function executeGeneration(array $data): void
|
|||||||
|
|
||||||
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
|
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
|
||||||
|
{
|
||||||
|
$tenant ??= Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function currentTenantContext(): ?Tenant
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant ? $tenant : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
|
||||||
|
{
|
||||||
|
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
|
||||||
|
{
|
||||||
|
$decision = static::reviewPackGenerationDecision($tenant);
|
||||||
|
|
||||||
|
if (! (bool) ($decision['is_blocked'] ?? false)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = $decision['block_reason'] ?? null;
|
||||||
|
|
||||||
|
return is_string($reason) && $reason !== '' ? $reason : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
|
||||||
|
{
|
||||||
|
$tenant ??= static::currentTenantContext();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant)) {
|
||||||
|
return AuthUiTooltips::insufficientPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::reviewPackGenerationBlockReason($tenant);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,7 @@
|
|||||||
namespace App\Filament\Resources\ReviewPackResource\Pages;
|
namespace App\Filament\Resources\ReviewPackResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\ReviewPackResource;
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Forms\Components\Toggle;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
|
|
||||||
class ListReviewPacks extends ListRecords
|
class ListReviewPacks extends ListRecords
|
||||||
{
|
{
|
||||||
@ -17,29 +12,13 @@ class ListReviewPacks extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
UiEnforcement::forAction(
|
ReviewPackResource::generatePackAction()
|
||||||
Actions\Action::make('generate_pack')
|
->visible(fn (): bool => $this->tableHasRecords()),
|
||||||
->label('Generate Pack')
|
|
||||||
->icon('heroicon-o-plus')
|
|
||||||
->action(function (array $data): void {
|
|
||||||
ReviewPackResource::executeGeneration($data);
|
|
||||||
})
|
|
||||||
->form([
|
|
||||||
Section::make('Pack options')
|
|
||||||
->schema([
|
|
||||||
Toggle::make('include_pii')
|
|
||||||
->label('Include PII')
|
|
||||||
->helperText('Include personally identifiable information in the export.')
|
|
||||||
->default(config('tenantpilot.review_pack.include_pii_default', true)),
|
|
||||||
Toggle::make('include_operations')
|
|
||||||
->label('Include operations')
|
|
||||||
->helperText('Include recent operation history in the export.')
|
|
||||||
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function tableHasRecords(): bool
|
||||||
|
{
|
||||||
|
return $this->getTableRecords()->count() > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,20 +19,12 @@ class ViewReviewPack extends ViewRecord
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
$regenerateAction = UiEnforcement::forAction(
|
||||||
Actions\Action::make('download')
|
|
||||||
->label('Download')
|
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
|
||||||
->color('success')
|
|
||||||
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
|
|
||||||
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
|
|
||||||
->openUrlInNewTab(),
|
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('regenerate')
|
Actions\Action::make('regenerate')
|
||||||
->label('Regenerate')
|
->label('Regenerate')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
|
->disabled(fn (): bool => ReviewPackResource::reviewPackGenerationBlocked($this->record->tenant))
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
|
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
@ -67,7 +59,21 @@ protected function getHeaderActions(): array
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||||
->apply(),
|
->preserveDisabled()
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
$regenerateAction->tooltip(fn (): ?string => ReviewPackResource::reviewPackGenerationActionTooltip($this->record->tenant));
|
||||||
|
|
||||||
|
return [
|
||||||
|
Actions\Action::make('download')
|
||||||
|
->label('Download')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->color('success')
|
||||||
|
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
|
||||||
|
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
|
||||||
|
->openUrlInNewTab(),
|
||||||
|
|
||||||
|
$regenerateAction,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\TenantReviewResource\Pages;
|
use App\Filament\Resources\TenantReviewResource\Pages;
|
||||||
|
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
@ -15,6 +16,7 @@
|
|||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
use App\Services\TenantReviews\TenantReviewService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
@ -241,6 +243,25 @@ public static function infolist(Schema $schema): Schema
|
|||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
|
$exportExecutivePackAction = UiEnforcement::forTableAction(
|
||||||
|
Actions\Action::make('export_executive_pack')
|
||||||
|
->label('Export executive pack')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
||||||
|
TenantReviewStatus::Ready->value,
|
||||||
|
TenantReviewStatus::Published->value,
|
||||||
|
], true))
|
||||||
|
->disabled(fn (TenantReview $record): bool => static::reviewPackGenerationBlocked($record->tenant))
|
||||||
|
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
|
||||||
|
fn (TenantReview $record): TenantReview => $record,
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->preserveDisabled()
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
$exportExecutivePackAction->tooltip(fn (TenantReview $record): ?string => static::reviewPackGenerationActionTooltip($record->tenant));
|
||||||
|
|
||||||
return $table
|
return $table
|
||||||
->defaultSort('generated_at', 'desc')
|
->defaultSort('generated_at', 'desc')
|
||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
@ -287,20 +308,7 @@ public static function table(Table $table): Table
|
|||||||
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
UiEnforcement::forTableAction(
|
$exportExecutivePackAction,
|
||||||
Actions\Action::make('export_executive_pack')
|
|
||||||
->label('Export executive pack')
|
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
|
||||||
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
|
||||||
TenantReviewStatus::Ready->value,
|
|
||||||
TenantReviewStatus::Published->value,
|
|
||||||
], true))
|
|
||||||
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
|
|
||||||
fn (TenantReview $record): TenantReview => $record,
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No tenant reviews yet')
|
->emptyStateHeading('No tenant reviews yet')
|
||||||
@ -423,6 +431,50 @@ public static function executeCreateReview(array $data): void
|
|||||||
$toast->send();
|
$toast->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
|
||||||
|
{
|
||||||
|
$tenant ??= Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
|
||||||
|
{
|
||||||
|
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
|
||||||
|
{
|
||||||
|
$decision = static::reviewPackGenerationDecision($tenant);
|
||||||
|
|
||||||
|
if (! (bool) ($decision['is_blocked'] ?? false)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = $decision['block_reason'] ?? null;
|
||||||
|
|
||||||
|
return is_string($reason) && $reason !== '' ? $reason : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
|
||||||
|
{
|
||||||
|
$tenant ??= static::panelTenantContext();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
|
||||||
|
return AuthUiTooltips::insufficientPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::reviewPackGenerationBlockReason($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
public static function executeExport(TenantReview $review): void
|
public static function executeExport(TenantReview $review): void
|
||||||
{
|
{
|
||||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
||||||
@ -457,6 +509,10 @@ public static function executeExport(TenantReview $review): void
|
|||||||
'include_pii' => true,
|
'include_pii' => true,
|
||||||
'include_operations' => true,
|
'include_operations' => true,
|
||||||
]);
|
]);
|
||||||
|
} catch (WorkspaceEntitlementBlockedException $exception) {
|
||||||
|
Notification::make()->warning()->title('Executive pack export unavailable')->body($exception->getMessage())->send();
|
||||||
|
|
||||||
|
return;
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
|
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
|
||||||
|
|
||||||
|
|||||||
@ -232,7 +232,7 @@ private function publishReviewAction(): Actions\Action
|
|||||||
|
|
||||||
private function exportExecutivePackAction(): Actions\Action
|
private function exportExecutivePackAction(): Actions\Action
|
||||||
{
|
{
|
||||||
return UiEnforcement::forAction(
|
$action = UiEnforcement::forAction(
|
||||||
Actions\Action::make('export_executive_pack')
|
Actions\Action::make('export_executive_pack')
|
||||||
->label('Export executive pack')
|
->label('Export executive pack')
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
@ -241,11 +241,17 @@ private function exportExecutivePackAction(): Actions\Action
|
|||||||
TenantReviewStatus::Ready->value,
|
TenantReviewStatus::Ready->value,
|
||||||
TenantReviewStatus::Published->value,
|
TenantReviewStatus::Published->value,
|
||||||
], true))
|
], true))
|
||||||
|
->disabled(fn (): bool => TenantReviewResource::reviewPackGenerationBlocked($this->record->tenant))
|
||||||
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
|
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
|
->preserveDisabled()
|
||||||
->apply();
|
->apply();
|
||||||
|
|
||||||
|
$action->tooltip(fn (): ?string => TenantReviewResource::reviewPackGenerationActionTooltip($this->record->tenant));
|
||||||
|
|
||||||
|
return $action;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createNextReviewAction(): Actions\Action
|
private function createNextReviewAction(): Actions\Action
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -62,6 +64,12 @@ 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,
|
||||||
]),
|
]),
|
||||||
|
|||||||
@ -0,0 +1,144 @@
|
|||||||
|
<?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,20 +4,25 @@
|
|||||||
|
|
||||||
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}';
|
||||||
@ -102,4 +107,26 @@ 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,19 +4,25 @@
|
|||||||
|
|
||||||
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\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
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}';
|
||||||
@ -79,4 +85,34 @@ public function runsUrl(): string
|
|||||||
{
|
{
|
||||||
return SystemOperationRunLinks::index();
|
return SystemOperationRunLinks::index();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function workspaceEntitlementSummary(): array
|
||||||
|
{
|
||||||
|
return app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -80,6 +80,9 @@ protected function getHeaderActions(): array
|
|||||||
$this->pauseRestoreExecuteAction(),
|
$this->pauseRestoreExecuteAction(),
|
||||||
$this->resumeRestoreExecuteAction(),
|
$this->resumeRestoreExecuteAction(),
|
||||||
$this->viewHistoryRestoreExecuteAction(),
|
$this->viewHistoryRestoreExecuteAction(),
|
||||||
|
$this->pauseAiExecutionAction(),
|
||||||
|
$this->resumeAiExecutionAction(),
|
||||||
|
$this->viewHistoryAiExecutionAction(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,6 +202,21 @@ public function viewHistoryRestoreExecuteAction(): Action
|
|||||||
return $this->historyActionFor('restore.execute');
|
return $this->historyActionFor('restore.execute');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function pauseAiExecutionAction(): Action
|
||||||
|
{
|
||||||
|
return $this->pauseActionFor('ai.execution');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resumeAiExecutionAction(): Action
|
||||||
|
{
|
||||||
|
return $this->resumeActionFor('ai.execution');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewHistoryAiExecutionAction(): Action
|
||||||
|
{
|
||||||
|
return $this->historyActionFor('ai.execution');
|
||||||
|
}
|
||||||
|
|
||||||
private function pauseActionFor(string $controlKey): Action
|
private function pauseActionFor(string $controlKey): Action
|
||||||
{
|
{
|
||||||
$label = app(OperationalControlCatalog::class)->label($controlKey);
|
$label = app(OperationalControlCatalog::class)->label($controlKey);
|
||||||
@ -213,7 +231,7 @@ private function pauseActionFor(string $controlKey): Action
|
|||||||
->form($this->pauseFormSchema($controlKey))
|
->form($this->pauseFormSchema($controlKey))
|
||||||
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
|
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
|
||||||
$actor = $this->controlsActor();
|
$actor = $this->controlsActor();
|
||||||
[$scopeType, $workspace, $reasonText, $expiresAt] = $this->normalizePauseInput($data);
|
[$scopeType, $workspace, $reasonText, $expiresAt] = $this->normalizePauseInput($controlKey, $data);
|
||||||
|
|
||||||
$scopeQuery = $this->activationScopeQuery($controlKey, $scopeType, $workspace);
|
$scopeQuery = $this->activationScopeQuery($controlKey, $scopeType, $workspace);
|
||||||
|
|
||||||
@ -273,7 +291,7 @@ private function resumeActionFor(string $controlKey): Action
|
|||||||
->form($this->resumeFormSchema($controlKey))
|
->form($this->resumeFormSchema($controlKey))
|
||||||
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
|
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
|
||||||
$actor = $this->controlsActor();
|
$actor = $this->controlsActor();
|
||||||
[$scopeType, $workspace] = $this->normalizeResumeInput($data);
|
[$scopeType, $workspace] = $this->normalizeResumeInput($controlKey, $data);
|
||||||
|
|
||||||
$activation = $this->activationScopeQuery($controlKey, $scopeType, $workspace)
|
$activation = $this->activationScopeQuery($controlKey, $scopeType, $workspace)
|
||||||
->notExpired()
|
->notExpired()
|
||||||
@ -331,11 +349,8 @@ private function pauseFormSchema(string $controlKey): array
|
|||||||
return [
|
return [
|
||||||
Radio::make('scope_type')
|
Radio::make('scope_type')
|
||||||
->label('Scope')
|
->label('Scope')
|
||||||
->options([
|
->options($this->scopeOptions($controlKey))
|
||||||
'global' => 'Global',
|
->default($this->defaultScopeFor($controlKey))
|
||||||
'workspace' => 'One workspace',
|
|
||||||
])
|
|
||||||
->default('global')
|
|
||||||
->live()
|
->live()
|
||||||
->required(),
|
->required(),
|
||||||
|
|
||||||
@ -395,11 +410,8 @@ private function resumeFormSchema(string $controlKey): array
|
|||||||
return [
|
return [
|
||||||
Radio::make('scope_type')
|
Radio::make('scope_type')
|
||||||
->label('Scope')
|
->label('Scope')
|
||||||
->options([
|
->options($this->scopeOptions($controlKey))
|
||||||
'global' => 'Global',
|
->default($this->defaultScopeFor($controlKey))
|
||||||
'workspace' => 'One workspace',
|
|
||||||
])
|
|
||||||
->default('global')
|
|
||||||
->live()
|
->live()
|
||||||
->required(),
|
->required(),
|
||||||
|
|
||||||
@ -456,9 +468,9 @@ private function controlsActor(): PlatformUser
|
|||||||
/**
|
/**
|
||||||
* @return array{0: string, 1: ?Workspace, 2: string, 3: ?CarbonInterface}
|
* @return array{0: string, 1: ?Workspace, 2: string, 3: ?CarbonInterface}
|
||||||
*/
|
*/
|
||||||
private function normalizePauseInput(array $data): array
|
private function normalizePauseInput(string $controlKey, array $data): array
|
||||||
{
|
{
|
||||||
[$scopeType, $workspace] = $this->resolveScopeInput($data);
|
[$scopeType, $workspace] = $this->resolveScopeInput($controlKey, $data);
|
||||||
$reasonText = trim((string) ($data['reason_text'] ?? ''));
|
$reasonText = trim((string) ($data['reason_text'] ?? ''));
|
||||||
|
|
||||||
if ($reasonText === '') {
|
if ($reasonText === '') {
|
||||||
@ -485,19 +497,20 @@ private function normalizePauseInput(array $data): array
|
|||||||
/**
|
/**
|
||||||
* @return array{0: string, 1: ?Workspace}
|
* @return array{0: string, 1: ?Workspace}
|
||||||
*/
|
*/
|
||||||
private function normalizeResumeInput(array $data): array
|
private function normalizeResumeInput(string $controlKey, array $data): array
|
||||||
{
|
{
|
||||||
return $this->resolveScopeInput($data);
|
return $this->resolveScopeInput($controlKey, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{0: string, 1: ?Workspace}
|
* @return array{0: string, 1: ?Workspace}
|
||||||
*/
|
*/
|
||||||
private function resolveScopeInput(array $data): array
|
private function resolveScopeInput(string $controlKey, array $data): array
|
||||||
{
|
{
|
||||||
$scopeType = (string) ($data['scope_type'] ?? 'global');
|
$scopeType = (string) ($data['scope_type'] ?? 'global');
|
||||||
|
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'] ?? ['global'];
|
||||||
|
|
||||||
if (! in_array($scopeType, ['global', 'workspace'], true)) {
|
if (! in_array($scopeType, $supportedScopes, true)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'scope_type' => 'Invalid scope selected.',
|
'scope_type' => 'Invalid scope selected.',
|
||||||
]);
|
]);
|
||||||
@ -526,6 +539,26 @@ private function resolveScopeInput(array $data): array
|
|||||||
return [$scopeType, $workspace];
|
return [$scopeType, $workspace];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function scopeOptions(string $controlKey): array
|
||||||
|
{
|
||||||
|
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
|
||||||
|
|
||||||
|
return Arr::only([
|
||||||
|
'global' => 'Global',
|
||||||
|
'workspace' => 'One workspace',
|
||||||
|
], $supportedScopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultScopeFor(string $controlKey): string
|
||||||
|
{
|
||||||
|
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
|
||||||
|
|
||||||
|
return $supportedScopes[0] ?? 'global';
|
||||||
|
}
|
||||||
|
|
||||||
private function activationScopeQuery(string $controlKey, string $scopeType, ?Workspace $workspace): \Illuminate\Database\Eloquent\Builder
|
private function activationScopeQuery(string $controlKey, string $scopeType, ?Workspace $workspace): \Illuminate\Database\Eloquent\Builder
|
||||||
{
|
{
|
||||||
$query = OperationalControlActivation::query()
|
$query = OperationalControlActivation::query()
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
<?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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
<?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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Tenant;
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
|
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -18,6 +19,7 @@
|
|||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
class TenantReviewPackCard extends Widget
|
class TenantReviewPackCard extends Widget
|
||||||
@ -66,6 +68,18 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
|
|||||||
/** @var ReviewPackService $service */
|
/** @var ReviewPackService $service */
|
||||||
$service = app(ReviewPackService::class);
|
$service = app(ReviewPackService::class);
|
||||||
|
|
||||||
|
$decision = $service->reviewPackGenerationDecisionForTenant($tenant);
|
||||||
|
|
||||||
|
if ((bool) ($decision['is_blocked'] ?? false)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Review pack generation unavailable')
|
||||||
|
->body((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'))
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$activeRun = $service->checkActiveRun($tenant)
|
$activeRun = $service->checkActiveRun($tenant)
|
||||||
? OperationRun::query()
|
? OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
@ -90,10 +104,20 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
$reviewPack = $service->generate($tenant, $user, [
|
$reviewPack = $service->generate($tenant, $user, [
|
||||||
'include_pii' => $includePii,
|
'include_pii' => $includePii,
|
||||||
'include_operations' => $includeOperations,
|
'include_operations' => $includeOperations,
|
||||||
]);
|
]);
|
||||||
|
} catch (WorkspaceEntitlementBlockedException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Review pack generation unavailable')
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$runUrl = $reviewPack->operationRun
|
$runUrl = $reviewPack->operationRun
|
||||||
? OperationRunLinks::tenantlessView($reviewPack->operationRun)
|
? OperationRunLinks::tenantlessView($reviewPack->operationRun)
|
||||||
@ -130,6 +154,14 @@ protected function getViewData(): array
|
|||||||
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
|
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
|
||||||
$canView = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
|
$canView = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
|
||||||
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
||||||
|
$service = app(ReviewPackService::class);
|
||||||
|
$generationEntitlement = $canManage
|
||||||
|
? $service->reviewPackGenerationDecisionForTenant($tenant)
|
||||||
|
: null;
|
||||||
|
$generationBlocked = (bool) ($generationEntitlement['is_blocked'] ?? false);
|
||||||
|
$generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null)
|
||||||
|
? $generationEntitlement['block_reason']
|
||||||
|
: null;
|
||||||
|
|
||||||
$latestPack = ReviewPack::query()
|
$latestPack = ReviewPack::query()
|
||||||
->with(['tenantReview', 'operationRun'])
|
->with(['tenantReview', 'operationRun'])
|
||||||
@ -146,6 +178,8 @@ protected function getViewData(): array
|
|||||||
'pollingInterval' => null,
|
'pollingInterval' => null,
|
||||||
'canView' => $canView,
|
'canView' => $canView,
|
||||||
'canManage' => $canManage,
|
'canManage' => $canManage,
|
||||||
|
'generationBlocked' => $generationBlocked,
|
||||||
|
'generationBlockReason' => $generationBlockReason,
|
||||||
'downloadUrl' => null,
|
'downloadUrl' => null,
|
||||||
'failedReason' => null,
|
'failedReason' => null,
|
||||||
'reviewUrl' => null,
|
'reviewUrl' => null,
|
||||||
@ -194,6 +228,8 @@ protected function getViewData(): array
|
|||||||
'pollingInterval' => self::resolvePollingInterval($latestPack),
|
'pollingInterval' => self::resolvePollingInterval($latestPack),
|
||||||
'canView' => $canView,
|
'canView' => $canView,
|
||||||
'canManage' => $canManage,
|
'canManage' => $canManage,
|
||||||
|
'generationBlocked' => $generationBlocked,
|
||||||
|
'generationBlockReason' => $generationBlockReason,
|
||||||
'downloadUrl' => $downloadUrl,
|
'downloadUrl' => $downloadUrl,
|
||||||
'failedReason' => $failedReason,
|
'failedReason' => $failedReason,
|
||||||
'failedReasonDetail' => $failedReasonDetail,
|
'failedReasonDetail' => $failedReasonDetail,
|
||||||
@ -224,6 +260,8 @@ private function emptyState(): array
|
|||||||
'pollingInterval' => null,
|
'pollingInterval' => null,
|
||||||
'canView' => false,
|
'canView' => false,
|
||||||
'canManage' => false,
|
'canManage' => false,
|
||||||
|
'generationBlocked' => false,
|
||||||
|
'generationBlockReason' => null,
|
||||||
'downloadUrl' => null,
|
'downloadUrl' => null,
|
||||||
'failedReason' => null,
|
'failedReason' => null,
|
||||||
'failedReasonDetail' => null,
|
'failedReasonDetail' => null,
|
||||||
|
|||||||
121
apps/platform/app/Models/SupportRequest.php
Normal file
121
apps/platform/app/Models/SupportRequest.php
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class SupportRequest extends Model
|
||||||
|
{
|
||||||
|
use DerivesWorkspaceIdFromTenant;
|
||||||
|
|
||||||
|
/** @use HasFactory<\Database\Factories\SupportRequestFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const string PRIMARY_CONTEXT_TENANT = 'tenant';
|
||||||
|
|
||||||
|
public const string PRIMARY_CONTEXT_OPERATION_RUN = 'operation_run';
|
||||||
|
|
||||||
|
public const string ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED = 'diagnostic_snapshot_attached';
|
||||||
|
|
||||||
|
public const string ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY = 'canonical_context_only';
|
||||||
|
|
||||||
|
public const string SEVERITY_LOW = 'low';
|
||||||
|
|
||||||
|
public const string SEVERITY_NORMAL = 'normal';
|
||||||
|
|
||||||
|
public const string SEVERITY_HIGH = 'high';
|
||||||
|
|
||||||
|
public const string SEVERITY_BLOCKING = 'blocking';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'context_envelope' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function severityOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::SEVERITY_LOW => 'Low',
|
||||||
|
self::SEVERITY_NORMAL => 'Normal',
|
||||||
|
self::SEVERITY_HIGH => 'High',
|
||||||
|
self::SEVERITY_BLOCKING => 'Blocking',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function severityValues(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::severityOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function primaryContextTypes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::PRIMARY_CONTEXT_TENANT,
|
||||||
|
self::PRIMARY_CONTEXT_OPERATION_RUN,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function attachmentModes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
|
||||||
|
self::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Workspace, $this>
|
||||||
|
*/
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Tenant, $this>
|
||||||
|
*/
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<OperationRun, $this>
|
||||||
|
*/
|
||||||
|
public function operationRun(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(OperationRun::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<User, $this>
|
||||||
|
*/
|
||||||
|
public function initiator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,9 +4,10 @@
|
|||||||
|
|
||||||
namespace App\Services\Audit;
|
namespace App\Services\Audit;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
@ -14,6 +15,7 @@
|
|||||||
use App\Support\Audit\AuditActorType;
|
use App\Support\Audit\AuditActorType;
|
||||||
use App\Support\Audit\AuditTargetSnapshot;
|
use App\Support\Audit\AuditTargetSnapshot;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
class WorkspaceAuditLogger
|
class WorkspaceAuditLogger
|
||||||
{
|
{
|
||||||
@ -136,4 +138,39 @@ public function logSupportDiagnosticsOpened(
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function logSupportRequestCreated(
|
||||||
|
SupportRequest $supportRequest,
|
||||||
|
User|PlatformUser|null $actor = null,
|
||||||
|
): \App\Models\AuditLog {
|
||||||
|
$supportRequest->loadMissing(['tenant.workspace']);
|
||||||
|
|
||||||
|
$tenant = $supportRequest->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new InvalidArgumentException('Support requests must belong to a tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->log(
|
||||||
|
workspace: $tenant->workspace,
|
||||||
|
action: AuditActionId::SupportRequestCreated,
|
||||||
|
context: [
|
||||||
|
'internal_reference' => $supportRequest->internal_reference,
|
||||||
|
'primary_context_type' => $supportRequest->primary_context_type,
|
||||||
|
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
|
||||||
|
? (string) $supportRequest->operation_run_id
|
||||||
|
: (string) $tenant->getKey(),
|
||||||
|
'attachment_mode' => $supportRequest->attachment_mode,
|
||||||
|
'redaction_mode' => (string) data_get($supportRequest->context_envelope, 'redaction_mode', 'default_redacted'),
|
||||||
|
],
|
||||||
|
actor: $actor,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'support_request',
|
||||||
|
resourceId: (string) $supportRequest->getKey(),
|
||||||
|
targetLabel: $supportRequest->internal_reference,
|
||||||
|
summary: 'Support request created for '.$supportRequest->internal_reference,
|
||||||
|
operationRunId: $supportRequest->operation_run_id !== null ? (int) $supportRequest->operation_run_id : null,
|
||||||
|
tenant: $tenant,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_DELETE,
|
Capabilities::TENANT_DELETE,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||||
|
Capabilities::SUPPORT_REQUESTS_CREATE,
|
||||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
Capabilities::TENANT_FINDINGS_VIEW,
|
Capabilities::TENANT_FINDINGS_VIEW,
|
||||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||||
@ -65,6 +66,7 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_MANAGE,
|
Capabilities::TENANT_MANAGE,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||||
|
Capabilities::SUPPORT_REQUESTS_CREATE,
|
||||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
Capabilities::TENANT_FINDINGS_VIEW,
|
Capabilities::TENANT_FINDINGS_VIEW,
|
||||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||||
@ -106,6 +108,7 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_VIEW,
|
Capabilities::TENANT_VIEW,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||||
|
Capabilities::SUPPORT_REQUESTS_CREATE,
|
||||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
Capabilities::TENANT_FINDINGS_VIEW,
|
Capabilities::TENANT_FINDINGS_VIEW,
|
||||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||||
|
|||||||
@ -0,0 +1,327 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Entitlements;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceSetting;
|
||||||
|
use App\Services\Settings\SettingsResolver;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
final class WorkspaceEntitlementResolver
|
||||||
|
{
|
||||||
|
public const SETTING_DOMAIN = 'entitlements';
|
||||||
|
|
||||||
|
public const SETTING_PLAN_PROFILE = 'plan_profile';
|
||||||
|
|
||||||
|
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE = 'managed_tenant_limit_override_value';
|
||||||
|
|
||||||
|
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON = 'managed_tenant_limit_override_reason';
|
||||||
|
|
||||||
|
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE = 'review_pack_generation_override_value';
|
||||||
|
|
||||||
|
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON = 'review_pack_generation_override_reason';
|
||||||
|
|
||||||
|
public const KEY_MANAGED_TENANT_ACTIVATION_LIMIT = 'managed_tenant_activation_limit';
|
||||||
|
|
||||||
|
public const KEY_REVIEW_PACK_GENERATION_ENABLED = 'review_pack_generation_enabled';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private SettingsResolver $settingsResolver,
|
||||||
|
private WorkspacePlanProfileCatalog $planProfileCatalog,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* plan_profile: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
|
||||||
|
* decisions: array<string, array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* plan_profile_id: string,
|
||||||
|
* plan_profile_label: string,
|
||||||
|
* plan_profile_description: string,
|
||||||
|
* key: string,
|
||||||
|
* effective_value: int|bool,
|
||||||
|
* source: 'plan_profile_default'|'workspace_override',
|
||||||
|
* rationale: string|null,
|
||||||
|
* current_usage: int|null,
|
||||||
|
* remaining_capacity: int|null,
|
||||||
|
* is_blocked: bool,
|
||||||
|
* block_reason: string|null,
|
||||||
|
* last_changed_at: CarbonInterface|null,
|
||||||
|
* last_changed_by: string|null
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function summary(Workspace $workspace): array
|
||||||
|
{
|
||||||
|
$planProfile = $this->resolvePlanProfile($workspace);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'plan_profile' => $planProfile,
|
||||||
|
'decisions' => [
|
||||||
|
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolve($workspace, self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, $planProfile),
|
||||||
|
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolve($workspace, self::KEY_REVIEW_PACK_GENERATION_ENABLED, $planProfile),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
||||||
|
*/
|
||||||
|
public function resolvePlanProfile(Workspace $workspace): array
|
||||||
|
{
|
||||||
|
$planProfileId = $this->settingsResolver->resolveValue(
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: self::SETTING_DOMAIN,
|
||||||
|
key: self::SETTING_PLAN_PROFILE,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->planProfileCatalog->resolve(is_string($planProfileId) ? $planProfileId : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null $planProfile
|
||||||
|
* @return array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* plan_profile_id: string,
|
||||||
|
* plan_profile_label: string,
|
||||||
|
* plan_profile_description: string,
|
||||||
|
* key: string,
|
||||||
|
* effective_value: int|bool,
|
||||||
|
* source: 'plan_profile_default'|'workspace_override',
|
||||||
|
* rationale: string|null,
|
||||||
|
* current_usage: int|null,
|
||||||
|
* remaining_capacity: int|null,
|
||||||
|
* is_blocked: bool,
|
||||||
|
* block_reason: string|null,
|
||||||
|
* last_changed_at: CarbonInterface|null,
|
||||||
|
* last_changed_by: string|null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function resolve(Workspace $workspace, string $key, ?array $planProfile = null): array
|
||||||
|
{
|
||||||
|
$planProfile ??= $this->resolvePlanProfile($workspace);
|
||||||
|
$lastChanged = $this->lastChangedMetadata($workspace);
|
||||||
|
|
||||||
|
return match ($key) {
|
||||||
|
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolveManagedTenantActivationLimitDecision($workspace, $planProfile, $lastChanged),
|
||||||
|
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolveReviewPackGenerationDecision($workspace, $planProfile, $lastChanged),
|
||||||
|
default => throw new \InvalidArgumentException(sprintf('Unknown workspace entitlement key: %s', $key)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
||||||
|
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
|
||||||
|
* @return array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* plan_profile_id: string,
|
||||||
|
* plan_profile_label: string,
|
||||||
|
* plan_profile_description: string,
|
||||||
|
* key: string,
|
||||||
|
* effective_value: int,
|
||||||
|
* source: 'plan_profile_default'|'workspace_override',
|
||||||
|
* rationale: string|null,
|
||||||
|
* current_usage: int,
|
||||||
|
* remaining_capacity: int,
|
||||||
|
* is_blocked: bool,
|
||||||
|
* block_reason: string|null,
|
||||||
|
* last_changed_at: CarbonInterface|null,
|
||||||
|
* last_changed_by: string|null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function resolveManagedTenantActivationLimitDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
|
||||||
|
{
|
||||||
|
$overrideValue = $this->settingsResolver->resolveDetailed(
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: self::SETTING_DOMAIN,
|
||||||
|
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
|
||||||
|
);
|
||||||
|
|
||||||
|
$overrideReason = $this->settingsResolver->resolveValue(
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: self::SETTING_DOMAIN,
|
||||||
|
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
|
||||||
|
);
|
||||||
|
|
||||||
|
$effectiveValue = is_int($overrideValue['value'])
|
||||||
|
? $overrideValue['value']
|
||||||
|
: (int) $planProfile['managed_tenant_limit_default'];
|
||||||
|
|
||||||
|
$source = $overrideValue['source'] === 'workspace_override'
|
||||||
|
? 'workspace_override'
|
||||||
|
: 'plan_profile_default';
|
||||||
|
|
||||||
|
$currentUsage = Tenant::activeQuery()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$remainingCapacity = $effectiveValue - $currentUsage;
|
||||||
|
$isBlocked = $currentUsage >= $effectiveValue;
|
||||||
|
$rationale = $source === 'workspace_override'
|
||||||
|
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
|
||||||
|
: (string) $planProfile['description'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'plan_profile_id' => (string) $planProfile['id'],
|
||||||
|
'plan_profile_label' => (string) $planProfile['label'],
|
||||||
|
'plan_profile_description' => (string) $planProfile['description'],
|
||||||
|
'key' => self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
||||||
|
'effective_value' => $effectiveValue,
|
||||||
|
'source' => $source,
|
||||||
|
'rationale' => $rationale,
|
||||||
|
'current_usage' => $currentUsage,
|
||||||
|
'remaining_capacity' => $remainingCapacity,
|
||||||
|
'is_blocked' => $isBlocked,
|
||||||
|
'block_reason' => $isBlocked
|
||||||
|
? $this->managedTenantLimitBlockReason($currentUsage, $effectiveValue, $source, $planProfile, $rationale)
|
||||||
|
: null,
|
||||||
|
'last_changed_at' => $lastChanged['last_changed_at'],
|
||||||
|
'last_changed_by' => $lastChanged['last_changed_by'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
||||||
|
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
|
||||||
|
* @return array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* plan_profile_id: string,
|
||||||
|
* plan_profile_label: string,
|
||||||
|
* plan_profile_description: string,
|
||||||
|
* key: string,
|
||||||
|
* effective_value: bool,
|
||||||
|
* source: 'plan_profile_default'|'workspace_override',
|
||||||
|
* rationale: string|null,
|
||||||
|
* current_usage: null,
|
||||||
|
* remaining_capacity: null,
|
||||||
|
* is_blocked: bool,
|
||||||
|
* block_reason: string|null,
|
||||||
|
* last_changed_at: CarbonInterface|null,
|
||||||
|
* last_changed_by: string|null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function resolveReviewPackGenerationDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
|
||||||
|
{
|
||||||
|
$overrideValue = $this->settingsResolver->resolveDetailed(
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: self::SETTING_DOMAIN,
|
||||||
|
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
|
||||||
|
);
|
||||||
|
|
||||||
|
$overrideReason = $this->settingsResolver->resolveValue(
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: self::SETTING_DOMAIN,
|
||||||
|
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
|
||||||
|
);
|
||||||
|
|
||||||
|
$effectiveValue = is_bool($overrideValue['value'])
|
||||||
|
? $overrideValue['value']
|
||||||
|
: (bool) $planProfile['review_pack_generation_default'];
|
||||||
|
|
||||||
|
$source = $overrideValue['source'] === 'workspace_override'
|
||||||
|
? 'workspace_override'
|
||||||
|
: 'plan_profile_default';
|
||||||
|
|
||||||
|
$rationale = $source === 'workspace_override'
|
||||||
|
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
|
||||||
|
: (string) $planProfile['description'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'plan_profile_id' => (string) $planProfile['id'],
|
||||||
|
'plan_profile_label' => (string) $planProfile['label'],
|
||||||
|
'plan_profile_description' => (string) $planProfile['description'],
|
||||||
|
'key' => self::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
||||||
|
'effective_value' => $effectiveValue,
|
||||||
|
'source' => $source,
|
||||||
|
'rationale' => $rationale,
|
||||||
|
'current_usage' => null,
|
||||||
|
'remaining_capacity' => null,
|
||||||
|
'is_blocked' => ! $effectiveValue,
|
||||||
|
'block_reason' => $effectiveValue
|
||||||
|
? null
|
||||||
|
: $this->reviewPackGenerationBlockReason($source, $planProfile, $rationale),
|
||||||
|
'last_changed_at' => $lastChanged['last_changed_at'],
|
||||||
|
'last_changed_by' => $lastChanged['last_changed_by'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
|
||||||
|
*/
|
||||||
|
private function lastChangedMetadata(Workspace $workspace): array
|
||||||
|
{
|
||||||
|
$record = WorkspaceSetting::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('domain', self::SETTING_DOMAIN)
|
||||||
|
->whereIn('key', [
|
||||||
|
self::SETTING_PLAN_PROFILE,
|
||||||
|
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
|
||||||
|
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
|
||||||
|
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
|
||||||
|
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
|
||||||
|
])
|
||||||
|
->whereNotNull('updated_by_user_id')
|
||||||
|
->with('updatedByUser:id,name')
|
||||||
|
->latest('updated_at')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $record instanceof WorkspaceSetting) {
|
||||||
|
return [
|
||||||
|
'last_changed_at' => null,
|
||||||
|
'last_changed_by' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'last_changed_at' => $record->updated_at,
|
||||||
|
'last_changed_by' => $record->updatedByUser?->name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
||||||
|
*/
|
||||||
|
private function managedTenantLimitBlockReason(int $currentUsage, int $effectiveValue, string $source, array $planProfile, ?string $rationale): string
|
||||||
|
{
|
||||||
|
$prefix = $source === 'workspace_override'
|
||||||
|
? 'This workspace override currently allows'
|
||||||
|
: sprintf('The %s plan profile currently allows', $planProfile['label']);
|
||||||
|
|
||||||
|
$message = sprintf(
|
||||||
|
'%s %d active managed tenant%s, and this workspace already has %d active managed tenant%s.',
|
||||||
|
$prefix,
|
||||||
|
$effectiveValue,
|
||||||
|
$effectiveValue === 1 ? '' : 's',
|
||||||
|
$currentUsage,
|
||||||
|
$currentUsage === 1 ? '' : 's',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($source === 'workspace_override' && $rationale !== null) {
|
||||||
|
$message .= ' Reason: '.$rationale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
||||||
|
*/
|
||||||
|
private function reviewPackGenerationBlockReason(string $source, array $planProfile, ?string $rationale): string
|
||||||
|
{
|
||||||
|
$message = $source === 'workspace_override'
|
||||||
|
? 'Review pack generation is disabled by workspace override.'
|
||||||
|
: sprintf('Review pack generation is disabled by the %s plan profile.', $planProfile['label']);
|
||||||
|
|
||||||
|
if ($source === 'workspace_override' && $rationale !== null) {
|
||||||
|
$message .= ' Reason: '.$rationale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Entitlements;
|
||||||
|
|
||||||
|
final class WorkspacePlanProfileCatalog
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
|
||||||
|
*/
|
||||||
|
private const PROFILES = [
|
||||||
|
'starter' => [
|
||||||
|
'id' => 'starter',
|
||||||
|
'label' => 'Starter',
|
||||||
|
'description' => 'Minimal allowance for early workspace access and low-volume operations.',
|
||||||
|
'managed_tenant_limit_default' => 1,
|
||||||
|
'review_pack_generation_default' => false,
|
||||||
|
'is_default' => false,
|
||||||
|
],
|
||||||
|
'standard' => [
|
||||||
|
'id' => 'standard',
|
||||||
|
'label' => 'Standard',
|
||||||
|
'description' => 'Balanced defaults for most managed workspaces.',
|
||||||
|
'managed_tenant_limit_default' => 25,
|
||||||
|
'review_pack_generation_default' => true,
|
||||||
|
'is_default' => true,
|
||||||
|
],
|
||||||
|
'scale' => [
|
||||||
|
'id' => 'scale',
|
||||||
|
'label' => 'Scale',
|
||||||
|
'description' => 'Higher managed-tenant capacity for larger workspace portfolios.',
|
||||||
|
'managed_tenant_limit_default' => 100,
|
||||||
|
'review_pack_generation_default' => true,
|
||||||
|
'is_default' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return array_values(self::PROFILES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
||||||
|
*/
|
||||||
|
public function default(): array
|
||||||
|
{
|
||||||
|
return self::PROFILES[self::defaultProfileId()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null
|
||||||
|
*/
|
||||||
|
public function find(?string $id): ?array
|
||||||
|
{
|
||||||
|
if ($id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::PROFILES[$id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
||||||
|
*/
|
||||||
|
public function resolve(?string $id): array
|
||||||
|
{
|
||||||
|
return $this->find($id) ?? $this->default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function optionLabels(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (array $profile): string => $profile['label'],
|
||||||
|
self::PROFILES,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function profileIds(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::PROFILES);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function defaultProfileId(): string
|
||||||
|
{
|
||||||
|
foreach (self::PROFILES as $id => $profile) {
|
||||||
|
if ($profile['is_default']) {
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException('Workspace plan profile catalog is missing a default profile.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
||||||
use App\Jobs\GenerateReviewPackJob;
|
use App\Jobs\GenerateReviewPackJob;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
@ -13,6 +14,7 @@
|
|||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
use App\Services\Evidence\EvidenceResolutionRequest;
|
use App\Services\Evidence\EvidenceResolutionRequest;
|
||||||
use App\Services\Evidence\EvidenceSnapshotResolver;
|
use App\Services\Evidence\EvidenceSnapshotResolver;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
@ -28,6 +30,7 @@ public function __construct(
|
|||||||
private OperationRunService $operationRunService,
|
private OperationRunService $operationRunService,
|
||||||
private EvidenceSnapshotResolver $snapshotResolver,
|
private EvidenceSnapshotResolver $snapshotResolver,
|
||||||
private WorkspaceAuditLogger $auditLogger,
|
private WorkspaceAuditLogger $auditLogger,
|
||||||
|
private WorkspaceEntitlementResolver $workspaceEntitlementResolver,
|
||||||
private ProductTelemetryRecorder $productTelemetryRecorder,
|
private ProductTelemetryRecorder $productTelemetryRecorder,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -49,6 +52,8 @@ public function __construct(
|
|||||||
*/
|
*/
|
||||||
public function generate(Tenant $tenant, User $user, array $options = []): ReviewPack
|
public function generate(Tenant $tenant, User $user, array $options = []): ReviewPack
|
||||||
{
|
{
|
||||||
|
$this->assertReviewPackGenerationAllowed($tenant);
|
||||||
|
|
||||||
$options = $this->normalizeOptions($options);
|
$options = $this->normalizeOptions($options);
|
||||||
$snapshot = $this->resolveSnapshot($tenant);
|
$snapshot = $this->resolveSnapshot($tenant);
|
||||||
$fingerprint = $this->computeFingerprintForSnapshot($snapshot, $options);
|
$fingerprint = $this->computeFingerprintForSnapshot($snapshot, $options);
|
||||||
@ -138,6 +143,8 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
|
|||||||
throw new \InvalidArgumentException('Review exports require an anchored evidence snapshot.');
|
throw new \InvalidArgumentException('Review exports require an anchored evidence snapshot.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertReviewPackGenerationAllowed($tenant);
|
||||||
|
|
||||||
$options = $this->normalizeOptions($options);
|
$options = $this->normalizeOptions($options);
|
||||||
$fingerprint = $this->computeFingerprintForReview($review, $options);
|
$fingerprint = $this->computeFingerprintForReview($review, $options);
|
||||||
$existing = $this->findExistingPackForReview($review, $fingerprint);
|
$existing = $this->findExistingPackForReview($review, $fingerprint);
|
||||||
@ -239,6 +246,17 @@ public function generateDownloadUrl(ReviewPack $pack): string
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
return $this->workspaceEntitlementResolver->resolve(
|
||||||
|
$tenant->workspace,
|
||||||
|
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void
|
private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void
|
||||||
{
|
{
|
||||||
$this->productTelemetryRecorder->record(
|
$this->productTelemetryRecorder->record(
|
||||||
@ -314,6 +332,17 @@ private function normalizeOptions(array $options): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function assertReviewPackGenerationAllowed(Tenant $tenant): void
|
||||||
|
{
|
||||||
|
$decision = $this->reviewPackGenerationDecisionForTenant($tenant);
|
||||||
|
|
||||||
|
if (! (bool) ($decision['is_blocked'] ?? false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new WorkspaceEntitlementBlockedException($decision);
|
||||||
|
}
|
||||||
|
|
||||||
private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string
|
private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string
|
||||||
{
|
{
|
||||||
$data = [
|
$data = [
|
||||||
|
|||||||
27
apps/platform/app/Support/Ai/AiDataClassification.php
Normal file
27
apps/platform/app/Support/Ai/AiDataClassification.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
enum AiDataClassification: string
|
||||||
|
{
|
||||||
|
case ProductKnowledge = 'product_knowledge';
|
||||||
|
case OperationalMetadata = 'operational_metadata';
|
||||||
|
case RedactedSupportSummary = 'redacted_support_summary';
|
||||||
|
case PersonalData = 'personal_data';
|
||||||
|
case CustomerConfidential = 'customer_confidential';
|
||||||
|
case RawProviderPayload = 'raw_provider_payload';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::ProductKnowledge => 'Product knowledge',
|
||||||
|
self::OperationalMetadata => 'Operational metadata',
|
||||||
|
self::RedactedSupportSummary => 'Redacted support summary',
|
||||||
|
self::PersonalData => 'Personal data',
|
||||||
|
self::CustomerConfidential => 'Customer confidential',
|
||||||
|
self::RawProviderPayload => 'Raw provider payload',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
final class AiDecisionAuditMetadataFactory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function make(AiExecutionRequest $request, AiExecutionDecision $decision): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'use_case_key' => $decision->useCaseKey,
|
||||||
|
'decision_outcome' => $decision->outcome,
|
||||||
|
'decision_reason' => $decision->reasonCode->value,
|
||||||
|
'workspace_ai_policy_mode' => $decision->workspaceAiPolicyMode,
|
||||||
|
'requested_provider_class' => $decision->requestedProviderClass,
|
||||||
|
'data_classifications' => $decision->dataClassifications,
|
||||||
|
'source_family' => $decision->sourceFamily,
|
||||||
|
'workspace_id' => $request->workspace?->getKey(),
|
||||||
|
'tenant_id' => $request->tenant?->getKey(),
|
||||||
|
'context_fingerprint' => $this->normalizedFingerprint($request->contextFingerprint),
|
||||||
|
'matched_operational_control_scope' => $decision->matchedOperationalControlScope,
|
||||||
|
], static fn (mixed $value): bool => $value !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizedFingerprint(?string $contextFingerprint): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($contextFingerprint)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim($contextFingerprint);
|
||||||
|
|
||||||
|
return $normalized === '' ? null : $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/platform/app/Support/Ai/AiDecisionReasonCode.php
Normal file
18
apps/platform/app/Support/Ai/AiDecisionReasonCode.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
enum AiDecisionReasonCode: string
|
||||||
|
{
|
||||||
|
case Allowed = 'allowed';
|
||||||
|
case MissingWorkspaceContext = 'missing_workspace_context';
|
||||||
|
case TenantOutsideWorkspace = 'tenant_outside_workspace';
|
||||||
|
case OperationalControlPaused = 'operational_control_paused';
|
||||||
|
case WorkspacePolicyDisabled = 'workspace_policy_disabled';
|
||||||
|
case UnregisteredUseCase = 'unregistered_use_case';
|
||||||
|
case ProviderClassBlocked = 'provider_class_blocked';
|
||||||
|
case DataClassificationBlocked = 'data_classification_blocked';
|
||||||
|
case SourceFamilyMismatch = 'source_family_mismatch';
|
||||||
|
}
|
||||||
37
apps/platform/app/Support/Ai/AiExecutionDecision.php
Normal file
37
apps/platform/app/Support/Ai/AiExecutionDecision.php
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
|
||||||
|
final readonly class AiExecutionDecision
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $dataClassifications
|
||||||
|
* @param array<string, mixed> $auditMetadata
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $outcome,
|
||||||
|
public AiDecisionReasonCode $reasonCode,
|
||||||
|
public string $workspaceAiPolicyMode,
|
||||||
|
public ?string $matchedOperationalControlScope,
|
||||||
|
public string $useCaseKey,
|
||||||
|
public string $requestedProviderClass,
|
||||||
|
public array $dataClassifications,
|
||||||
|
public string $sourceFamily,
|
||||||
|
public AuditActionId $auditAction,
|
||||||
|
public array $auditMetadata,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function isAllowed(): bool
|
||||||
|
{
|
||||||
|
return $this->outcome === 'allowed';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isBlocked(): bool
|
||||||
|
{
|
||||||
|
return $this->outcome === 'blocked';
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/platform/app/Support/Ai/AiExecutionRequest.php
Normal file
28
apps/platform/app/Support/Ai/AiExecutionRequest.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
|
||||||
|
final readonly class AiExecutionRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $dataClassifications
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public ?Workspace $workspace,
|
||||||
|
public ?Tenant $tenant,
|
||||||
|
public User|PlatformUser|null $actor,
|
||||||
|
public string $useCaseKey,
|
||||||
|
public string $requestedProviderClass,
|
||||||
|
public array $dataClassifications,
|
||||||
|
public string $sourceFamily,
|
||||||
|
public ?string $callerSurface = null,
|
||||||
|
public ?string $contextFingerprint = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
43
apps/platform/app/Support/Ai/AiPolicyMode.php
Normal file
43
apps/platform/app/Support/Ai/AiPolicyMode.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
enum AiPolicyMode: string
|
||||||
|
{
|
||||||
|
case Disabled = 'disabled';
|
||||||
|
case PrivateOnly = 'private_only';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Disabled => 'Disabled',
|
||||||
|
self::PrivateOnly => 'Private only',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function summary(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Disabled => 'No AI execution is allowed for this workspace.',
|
||||||
|
self::PrivateOnly => 'Only approved internal drafts may use private-only AI for approved use cases.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function optionLabels(): array
|
||||||
|
{
|
||||||
|
return array_reduce(
|
||||||
|
self::cases(),
|
||||||
|
static function (array $labels, self $mode): array {
|
||||||
|
$labels[$mode->value] = $mode->label();
|
||||||
|
|
||||||
|
return $labels;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/platform/app/Support/Ai/AiProviderClass.php
Normal file
19
apps/platform/app/Support/Ai/AiProviderClass.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
enum AiProviderClass: string
|
||||||
|
{
|
||||||
|
case LocalPrivate = 'local_private';
|
||||||
|
case ExternalPublic = 'external_public';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::LocalPrivate => 'Local private',
|
||||||
|
self::ExternalPublic => 'External public',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
126
apps/platform/app/Support/Ai/AiUseCaseCatalog.php
Normal file
126
apps/platform/app/Support/Ai/AiUseCaseCatalog.php
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
final class AiUseCaseCatalog
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, array{
|
||||||
|
* key: string,
|
||||||
|
* label: string,
|
||||||
|
* future_consumer: string,
|
||||||
|
* visibility: string,
|
||||||
|
* allowed_provider_classes: list<string>,
|
||||||
|
* allowed_data_classifications: list<string>,
|
||||||
|
* source_family: string,
|
||||||
|
* tenant_context_permitted: bool
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private const USE_CASES = [
|
||||||
|
'product_knowledge.answer_draft' => [
|
||||||
|
'key' => 'product_knowledge.answer_draft',
|
||||||
|
'label' => 'Product knowledge answer draft',
|
||||||
|
'future_consumer' => 'ContextualHelpResolver',
|
||||||
|
'visibility' => 'internal_only_draft',
|
||||||
|
'allowed_provider_classes' => [AiProviderClass::LocalPrivate->value],
|
||||||
|
'allowed_data_classifications' => [
|
||||||
|
AiDataClassification::ProductKnowledge->value,
|
||||||
|
AiDataClassification::OperationalMetadata->value,
|
||||||
|
],
|
||||||
|
'source_family' => 'product_knowledge',
|
||||||
|
'tenant_context_permitted' => false,
|
||||||
|
],
|
||||||
|
'support_diagnostics.summary_draft' => [
|
||||||
|
'key' => 'support_diagnostics.summary_draft',
|
||||||
|
'label' => 'Support diagnostics summary draft',
|
||||||
|
'future_consumer' => 'SupportDiagnosticBundleBuilder',
|
||||||
|
'visibility' => 'internal_only_draft',
|
||||||
|
'allowed_provider_classes' => [AiProviderClass::LocalPrivate->value],
|
||||||
|
'allowed_data_classifications' => [AiDataClassification::RedactedSupportSummary->value],
|
||||||
|
'source_family' => 'support_diagnostics',
|
||||||
|
'tenant_context_permitted' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* key: string,
|
||||||
|
* label: string,
|
||||||
|
* future_consumer: string,
|
||||||
|
* visibility: string,
|
||||||
|
* allowed_provider_classes: list<string>,
|
||||||
|
* allowed_data_classifications: list<string>,
|
||||||
|
* source_family: string,
|
||||||
|
* tenant_context_permitted: bool
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return array_values(self::USE_CASES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* key: string,
|
||||||
|
* label: string,
|
||||||
|
* future_consumer: string,
|
||||||
|
* visibility: string,
|
||||||
|
* allowed_provider_classes: list<string>,
|
||||||
|
* allowed_data_classifications: list<string>,
|
||||||
|
* source_family: string,
|
||||||
|
* tenant_context_permitted: bool
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public function find(string $key): ?array
|
||||||
|
{
|
||||||
|
return self::USE_CASES[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function labels(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (array $definition): string => $definition['label'],
|
||||||
|
$this->all(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function allowedProviderClassLabelsForMode(AiPolicyMode $mode): array
|
||||||
|
{
|
||||||
|
if ($mode === AiPolicyMode::Disabled) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
|
||||||
|
foreach ($this->all() as $definition) {
|
||||||
|
foreach ($definition['allowed_provider_classes'] as $providerClass) {
|
||||||
|
$labels[$providerClass] = AiProviderClass::from($providerClass)->label();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function blockedDataClassificationLabels(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (AiDataClassification $classification): string => $classification->label(),
|
||||||
|
[
|
||||||
|
AiDataClassification::PersonalData,
|
||||||
|
AiDataClassification::CustomerConfidential,
|
||||||
|
AiDataClassification::RawProviderPayload,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
apps/platform/app/Support/Ai/GovernedAiExecutionBoundary.php
Normal file
181
apps/platform/app/Support/Ai/GovernedAiExecutionBoundary.php
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Settings\SettingsResolver;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\OperationalControls\OperationalControlEvaluator;
|
||||||
|
|
||||||
|
final class GovernedAiExecutionBoundary
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AiUseCaseCatalog $useCaseCatalog,
|
||||||
|
private readonly SettingsResolver $settingsResolver,
|
||||||
|
private readonly OperationalControlEvaluator $operationalControls,
|
||||||
|
private readonly AiDecisionAuditMetadataFactory $auditMetadataFactory,
|
||||||
|
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function evaluate(AiExecutionRequest $request): AiExecutionDecision
|
||||||
|
{
|
||||||
|
$decision = $this->decisionFor($request);
|
||||||
|
$metadata = $this->auditMetadataFactory->make($request, $decision);
|
||||||
|
|
||||||
|
$decision = new AiExecutionDecision(
|
||||||
|
outcome: $decision->outcome,
|
||||||
|
reasonCode: $decision->reasonCode,
|
||||||
|
workspaceAiPolicyMode: $decision->workspaceAiPolicyMode,
|
||||||
|
matchedOperationalControlScope: $decision->matchedOperationalControlScope,
|
||||||
|
useCaseKey: $decision->useCaseKey,
|
||||||
|
requestedProviderClass: $decision->requestedProviderClass,
|
||||||
|
dataClassifications: $decision->dataClassifications,
|
||||||
|
sourceFamily: $decision->sourceFamily,
|
||||||
|
auditAction: $decision->auditAction,
|
||||||
|
auditMetadata: $metadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($request->workspace !== null) {
|
||||||
|
$definition = $this->useCaseCatalog->find($request->useCaseKey);
|
||||||
|
|
||||||
|
$this->workspaceAuditLogger->log(
|
||||||
|
workspace: $request->workspace,
|
||||||
|
action: $decision->auditAction,
|
||||||
|
context: ['metadata' => $decision->auditMetadata],
|
||||||
|
actor: $request->actor,
|
||||||
|
status: $decision->isAllowed() ? 'success' : 'blocked',
|
||||||
|
resourceType: 'ai_use_case',
|
||||||
|
resourceId: $request->useCaseKey,
|
||||||
|
targetLabel: $definition['label'] ?? $request->useCaseKey,
|
||||||
|
summary: 'AI execution decision evaluated',
|
||||||
|
tenant: $request->tenant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decisionFor(AiExecutionRequest $request): AiExecutionDecision
|
||||||
|
{
|
||||||
|
if ($request->workspace === null) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::MissingWorkspaceContext,
|
||||||
|
workspaceAiPolicyMode: AiPolicyMode::Disabled->value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->tenant !== null && (int) $request->tenant->workspace_id !== (int) $request->workspace->getKey()) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::TenantOutsideWorkspace,
|
||||||
|
workspaceAiPolicyMode: AiPolicyMode::Disabled->value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$controlDecision = $this->operationalControls->evaluate('ai.execution', $request->workspace);
|
||||||
|
|
||||||
|
if ($controlDecision->isPaused()) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::OperationalControlPaused,
|
||||||
|
workspaceAiPolicyMode: $this->resolvedPolicyMode($request),
|
||||||
|
matchedOperationalControlScope: $controlDecision->matchedScopeType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$policyMode = $this->resolvedPolicyMode($request);
|
||||||
|
|
||||||
|
if ($policyMode === AiPolicyMode::Disabled->value) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::WorkspacePolicyDisabled,
|
||||||
|
workspaceAiPolicyMode: $policyMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$definition = $this->useCaseCatalog->find($request->useCaseKey);
|
||||||
|
|
||||||
|
if ($definition === null) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::UnregisteredUseCase,
|
||||||
|
workspaceAiPolicyMode: $policyMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($definition['source_family'] !== $request->sourceFamily) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::SourceFamilyMismatch,
|
||||||
|
workspaceAiPolicyMode: $policyMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($request->requestedProviderClass, $definition['allowed_provider_classes'], true)) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::ProviderClassBlocked,
|
||||||
|
workspaceAiPolicyMode: $policyMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($request->dataClassifications as $classification) {
|
||||||
|
if (! in_array($classification, $definition['allowed_data_classifications'], true)) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::DataClassificationBlocked,
|
||||||
|
workspaceAiPolicyMode: $policyMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AiExecutionDecision(
|
||||||
|
outcome: 'allowed',
|
||||||
|
reasonCode: AiDecisionReasonCode::Allowed,
|
||||||
|
workspaceAiPolicyMode: $policyMode,
|
||||||
|
matchedOperationalControlScope: null,
|
||||||
|
useCaseKey: $request->useCaseKey,
|
||||||
|
requestedProviderClass: $request->requestedProviderClass,
|
||||||
|
dataClassifications: $request->dataClassifications,
|
||||||
|
sourceFamily: $request->sourceFamily,
|
||||||
|
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
|
||||||
|
auditMetadata: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvedPolicyMode(AiExecutionRequest $request): string
|
||||||
|
{
|
||||||
|
if ($request->workspace === null) {
|
||||||
|
return AiPolicyMode::Disabled->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = $this->settingsResolver->resolveValue($request->workspace, 'ai', 'policy_mode');
|
||||||
|
|
||||||
|
return is_string($resolved) && $resolved !== ''
|
||||||
|
? $resolved
|
||||||
|
: AiPolicyMode::Disabled->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function blockedDecision(
|
||||||
|
AiExecutionRequest $request,
|
||||||
|
AiDecisionReasonCode $reasonCode,
|
||||||
|
string $workspaceAiPolicyMode,
|
||||||
|
?string $matchedOperationalControlScope = null,
|
||||||
|
): AiExecutionDecision {
|
||||||
|
return new AiExecutionDecision(
|
||||||
|
outcome: 'blocked',
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
workspaceAiPolicyMode: $workspaceAiPolicyMode,
|
||||||
|
matchedOperationalControlScope: $matchedOperationalControlScope,
|
||||||
|
useCaseKey: $request->useCaseKey,
|
||||||
|
requestedProviderClass: $request->requestedProviderClass,
|
||||||
|
dataClassifications: $request->dataClassifications,
|
||||||
|
sourceFamily: $request->sourceFamily,
|
||||||
|
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
|
||||||
|
auditMetadata: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -100,6 +100,8 @@ enum AuditActionId: string
|
|||||||
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
|
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
|
||||||
|
|
||||||
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
|
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
|
||||||
|
case SupportRequestCreated = 'support_request.created';
|
||||||
|
case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated';
|
||||||
case OperationalControlPaused = 'operational_control.paused';
|
case OperationalControlPaused = 'operational_control.paused';
|
||||||
case OperationalControlUpdated = 'operational_control.updated';
|
case OperationalControlUpdated = 'operational_control.updated';
|
||||||
case OperationalControlResumed = 'operational_control.resumed';
|
case OperationalControlResumed = 'operational_control.resumed';
|
||||||
@ -241,6 +243,8 @@ private static function labels(): array
|
|||||||
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
|
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
|
||||||
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
|
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
|
||||||
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
||||||
|
self::SupportRequestCreated->value => 'Support request created',
|
||||||
|
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
|
||||||
self::OperationalControlPaused->value => 'Operational control paused',
|
self::OperationalControlPaused->value => 'Operational control paused',
|
||||||
self::OperationalControlUpdated->value => 'Operational control updated',
|
self::OperationalControlUpdated->value => 'Operational control updated',
|
||||||
self::OperationalControlResumed->value => 'Operational control resumed',
|
self::OperationalControlResumed->value => 'Operational control resumed',
|
||||||
@ -327,6 +331,8 @@ private static function summaries(): array
|
|||||||
self::TenantReviewExported->value => 'Tenant review exported',
|
self::TenantReviewExported->value => 'Tenant review exported',
|
||||||
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
||||||
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
||||||
|
self::SupportRequestCreated->value => 'Support request created',
|
||||||
|
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
|
||||||
self::OperationalControlPaused->value => 'Operational control paused',
|
self::OperationalControlPaused->value => 'Operational control paused',
|
||||||
self::OperationalControlUpdated->value => 'Operational control updated',
|
self::OperationalControlUpdated->value => 'Operational control updated',
|
||||||
self::OperationalControlResumed->value => 'Operational control resumed',
|
self::OperationalControlResumed->value => 'Operational control resumed',
|
||||||
|
|||||||
@ -72,6 +72,9 @@ class Capabilities
|
|||||||
// Support diagnostics
|
// Support diagnostics
|
||||||
public const SUPPORT_DIAGNOSTICS_VIEW = 'support_diagnostics.view';
|
public const SUPPORT_DIAGNOSTICS_VIEW = 'support_diagnostics.view';
|
||||||
|
|
||||||
|
// Support requests
|
||||||
|
public const SUPPORT_REQUESTS_CREATE = 'support_requests.create';
|
||||||
|
|
||||||
// Inventory
|
// Inventory
|
||||||
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';
|
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,112 @@
|
|||||||
|
<?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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,766 @@
|
|||||||
|
<?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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,13 @@ final class OperationalControlCatalog
|
|||||||
'operation_types' => ['restore.execute'],
|
'operation_types' => ['restore.execute'],
|
||||||
'affected_surfaces' => ['tenant.restore_runs.create'],
|
'affected_surfaces' => ['tenant.restore_runs.create'],
|
||||||
],
|
],
|
||||||
|
'ai.execution' => [
|
||||||
|
'key' => 'ai.execution',
|
||||||
|
'label' => 'AI execution',
|
||||||
|
'supported_scopes' => ['global'],
|
||||||
|
'operation_types' => ['ai.execution'],
|
||||||
|
'affected_surfaces' => ['governed_ai.execution'],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -0,0 +1,263 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,465 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\ProductKnowledge;
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Ai\AiDataClassification;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* use_case_key: string,
|
||||||
|
* source_family: string,
|
||||||
|
* data_classifications: list<string>,
|
||||||
|
* operational_metadata: 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 aiProductKnowledgeAnswerDraftSource(): array
|
||||||
|
{
|
||||||
|
$source = $this->knowledgeSource();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'use_case_key' => 'product_knowledge.answer_draft',
|
||||||
|
'source_family' => 'product_knowledge',
|
||||||
|
'data_classifications' => [
|
||||||
|
AiDataClassification::ProductKnowledge->value,
|
||||||
|
AiDataClassification::OperationalMetadata->value,
|
||||||
|
],
|
||||||
|
'operational_metadata' => [
|
||||||
|
'version' => (int) $source['version'],
|
||||||
|
'topic_count' => (int) $source['topic_count'],
|
||||||
|
],
|
||||||
|
'topics' => $source['topics'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Support\Settings;
|
namespace App\Support\Settings;
|
||||||
|
|
||||||
|
use App\Support\Ai\AiPolicyMode;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
||||||
|
|
||||||
final class SettingsRegistry
|
final class SettingsRegistry
|
||||||
{
|
{
|
||||||
@ -17,6 +19,15 @@ public function __construct()
|
|||||||
{
|
{
|
||||||
$this->definitions = [];
|
$this->definitions = [];
|
||||||
|
|
||||||
|
$this->register(new SettingDefinition(
|
||||||
|
domain: 'ai',
|
||||||
|
key: 'policy_mode',
|
||||||
|
type: 'string',
|
||||||
|
systemDefault: AiPolicyMode::Disabled->value,
|
||||||
|
rules: ['required', 'string', 'in:disabled,private_only'],
|
||||||
|
normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)),
|
||||||
|
));
|
||||||
|
|
||||||
$this->register(new SettingDefinition(
|
$this->register(new SettingDefinition(
|
||||||
domain: 'backup',
|
domain: 'backup',
|
||||||
key: 'retention_keep_last_default',
|
key: 'retention_keep_last_default',
|
||||||
@ -218,6 +229,91 @@ static function (string $attribute, mixed $value, \Closure $fail): void {
|
|||||||
rules: ['required', 'integer', 'min:0', 'max:10080'],
|
rules: ['required', 'integer', 'min:0', 'max:10080'],
|
||||||
normalizer: static fn (mixed $value): int => (int) $value,
|
normalizer: static fn (mixed $value): int => (int) $value,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
$this->register(new SettingDefinition(
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'plan_profile',
|
||||||
|
type: 'string',
|
||||||
|
systemDefault: WorkspacePlanProfileCatalog::defaultProfileId(),
|
||||||
|
rules: [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'in:'.implode(',', WorkspacePlanProfileCatalog::profileIds()),
|
||||||
|
],
|
||||||
|
normalizer: static function (mixed $value): ?string {
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim((string) $value);
|
||||||
|
|
||||||
|
return $normalized === '' ? null : $normalized;
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->register(new SettingDefinition(
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'managed_tenant_limit_override_value',
|
||||||
|
type: 'int',
|
||||||
|
systemDefault: null,
|
||||||
|
rules: ['nullable', 'integer', 'min:0'],
|
||||||
|
normalizer: static function (mixed $value): ?int {
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $value;
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->register(new SettingDefinition(
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'managed_tenant_limit_override_reason',
|
||||||
|
type: 'string',
|
||||||
|
systemDefault: null,
|
||||||
|
rules: ['nullable', 'string', 'max:500'],
|
||||||
|
normalizer: static function (mixed $value): ?string {
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim((string) $value);
|
||||||
|
|
||||||
|
return $normalized === '' ? null : $normalized;
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->register(new SettingDefinition(
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'review_pack_generation_override_value',
|
||||||
|
type: 'bool',
|
||||||
|
systemDefault: null,
|
||||||
|
rules: ['nullable', 'boolean'],
|
||||||
|
normalizer: static function (mixed $value): ?bool {
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter_var($value, FILTER_VALIDATE_BOOL);
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->register(new SettingDefinition(
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'review_pack_generation_override_reason',
|
||||||
|
type: 'string',
|
||||||
|
systemDefault: null,
|
||||||
|
rules: ['nullable', 'string', 'max:500'],
|
||||||
|
normalizer: static function (mixed $value): ?string {
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim((string) $value);
|
||||||
|
|
||||||
|
return $normalized === '' ? null : $normalized;
|
||||||
|
},
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -19,9 +19,11 @@
|
|||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Ai\AiDataClassification;
|
||||||
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;
|
||||||
@ -47,6 +49,7 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -69,6 +72,7 @@ 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),
|
||||||
@ -109,6 +113,7 @@ 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(
|
||||||
@ -129,6 +134,39 @@ public function forOperationRun(OperationRun $run, ?User $actor = null): array
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* use_case_key: string,
|
||||||
|
* source_family: string,
|
||||||
|
* data_classifications: list<string>,
|
||||||
|
* summary: array{
|
||||||
|
* headline: string,
|
||||||
|
* dominant_issue: string,
|
||||||
|
* freshness_state: string,
|
||||||
|
* completeness_note: ?string,
|
||||||
|
* redaction_note: string,
|
||||||
|
* generated_from: string
|
||||||
|
* },
|
||||||
|
* redaction: array{mode: string, markers: list<string>},
|
||||||
|
* notes: list<string>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function aiSupportDiagnosticsSummaryDraftSource(Tenant $tenant, ?User $actor = null): array
|
||||||
|
{
|
||||||
|
$bundle = $this->forTenant($tenant, $actor);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'use_case_key' => 'support_diagnostics.summary_draft',
|
||||||
|
'source_family' => 'support_diagnostics',
|
||||||
|
'data_classifications' => [
|
||||||
|
AiDataClassification::RedactedSupportSummary->value,
|
||||||
|
],
|
||||||
|
'summary' => $bundle['summary'],
|
||||||
|
'redaction' => $bundle['redaction'],
|
||||||
|
'notes' => $bundle['notes'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<array<string, mixed>> $sections
|
* @param list<array<string, mixed>> $sections
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
@ -137,6 +175,7 @@ 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,
|
||||||
@ -144,6 +183,7 @@ 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,
|
||||||
@ -173,6 +213,7 @@ 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',
|
||||||
@ -185,6 +226,60 @@ 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()
|
||||||
|
|||||||
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\SupportRequests;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||||
|
|
||||||
|
final class SupportRequestContextBuilder
|
||||||
|
{
|
||||||
|
public const ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED = 'diagnostic_snapshot_attached';
|
||||||
|
|
||||||
|
public const ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY = 'canonical_context_only';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly SupportDiagnosticBundleBuilder $supportDiagnosticBundleBuilder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function forTenant(Tenant $tenant, User $actor, bool $attachDiagnosticSnapshot): array
|
||||||
|
{
|
||||||
|
return $this->buildEnvelope(
|
||||||
|
bundle: $this->supportDiagnosticBundleBuilder->forTenant($tenant, $actor),
|
||||||
|
attachDiagnosticSnapshot: $attachDiagnosticSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function forOperationRun(OperationRun $run, User $actor, bool $attachDiagnosticSnapshot): array
|
||||||
|
{
|
||||||
|
return $this->buildEnvelope(
|
||||||
|
bundle: $this->supportDiagnosticBundleBuilder->forOperationRun($run, $actor),
|
||||||
|
attachDiagnosticSnapshot: $attachDiagnosticSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $bundle
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function buildEnvelope(array $bundle, bool $attachDiagnosticSnapshot): array
|
||||||
|
{
|
||||||
|
$attachmentMode = $attachDiagnosticSnapshot
|
||||||
|
? self::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED
|
||||||
|
: self::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'schema_version' => 1,
|
||||||
|
'generated_from' => 'support_diagnostics_bundle',
|
||||||
|
'attachment_mode' => $attachmentMode,
|
||||||
|
'redaction_mode' => (string) data_get($bundle, 'redaction.mode', 'default_redacted'),
|
||||||
|
'primary_context' => [
|
||||||
|
'type' => (string) data_get($bundle, 'context.type'),
|
||||||
|
'workspace_id' => data_get($bundle, 'context.workspace_id'),
|
||||||
|
'tenant_id' => data_get($bundle, 'context.tenant_id'),
|
||||||
|
'operation_run_id' => data_get($bundle, 'context.operation_run_id'),
|
||||||
|
'workspace_label' => data_get($bundle, 'context.workspace_label'),
|
||||||
|
'tenant_label' => data_get($bundle, 'context.tenant_label'),
|
||||||
|
],
|
||||||
|
'canonical_context' => [
|
||||||
|
'headline' => (string) data_get($bundle, 'summary.headline', data_get($bundle, 'headline')),
|
||||||
|
'dominant_issue' => (string) data_get($bundle, 'summary.dominant_issue', data_get($bundle, 'dominant_issue')),
|
||||||
|
'freshness_state' => (string) data_get($bundle, 'freshness_state'),
|
||||||
|
'completeness_note' => data_get($bundle, 'summary.completeness_note'),
|
||||||
|
'redaction_note' => data_get($bundle, 'summary.redaction_note'),
|
||||||
|
'context' => data_get($bundle, 'context', []),
|
||||||
|
'tenant' => data_get($bundle, 'tenant'),
|
||||||
|
'operation_run' => data_get($bundle, 'operation_run'),
|
||||||
|
'sections' => $this->canonicalSections($bundle),
|
||||||
|
'notes' => is_array($bundle['notes'] ?? null)
|
||||||
|
? array_values($bundle['notes'])
|
||||||
|
: [],
|
||||||
|
],
|
||||||
|
'diagnostic_snapshot' => $attachDiagnosticSnapshot
|
||||||
|
? [
|
||||||
|
'contextual_help' => data_get($bundle, 'contextual_help'),
|
||||||
|
'sections' => is_array($bundle['sections'] ?? null)
|
||||||
|
? array_values($bundle['sections'])
|
||||||
|
: [],
|
||||||
|
'redaction' => is_array($bundle['redaction'] ?? null)
|
||||||
|
? $bundle['redaction']
|
||||||
|
: [],
|
||||||
|
'notes' => is_array($bundle['notes'] ?? null)
|
||||||
|
? array_values($bundle['notes'])
|
||||||
|
: [],
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
'omissions' => $attachDiagnosticSnapshot
|
||||||
|
? []
|
||||||
|
: [[
|
||||||
|
'type' => 'diagnostic_snapshot',
|
||||||
|
'reason' => 'omitted_without_support_diagnostics_view',
|
||||||
|
'message' => 'Redacted diagnostic evidence was omitted because the creator could not view support diagnostics.',
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $bundle
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function canonicalSections(array $bundle): array
|
||||||
|
{
|
||||||
|
if (! is_array($bundle['sections'] ?? null)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_map(
|
||||||
|
static fn (array $section): array => [
|
||||||
|
'key' => (string) ($section['key'] ?? ''),
|
||||||
|
'label' => (string) ($section['label'] ?? ''),
|
||||||
|
'availability' => (string) ($section['availability'] ?? 'missing'),
|
||||||
|
'summary' => (string) ($section['summary'] ?? ''),
|
||||||
|
'freshness_note' => $section['freshness_note'] ?? null,
|
||||||
|
'references' => is_array($section['references'] ?? null)
|
||||||
|
? array_values($section['references'])
|
||||||
|
: [],
|
||||||
|
],
|
||||||
|
$bundle['sections'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\SupportRequests;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class SupportRequestReferenceGenerator
|
||||||
|
{
|
||||||
|
public function generate(): string
|
||||||
|
{
|
||||||
|
return 'SR-'.strtoupper((string) Str::ulid());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\SupportRequests;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
final class SupportRequestSubmissionService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
|
private readonly SupportRequestContextBuilder $supportRequestContextBuilder,
|
||||||
|
private readonly SupportRequestReferenceGenerator $supportRequestReferenceGenerator,
|
||||||
|
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function submitForTenant(Tenant $tenant, User $actor, array $data): SupportRequest
|
||||||
|
{
|
||||||
|
$this->authorizeCreation($tenant, $actor);
|
||||||
|
|
||||||
|
return $this->submit(
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $actor,
|
||||||
|
data: $data,
|
||||||
|
primaryContextType: SupportRequest::PRIMARY_CONTEXT_TENANT,
|
||||||
|
operationRun: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function submitForOperationRun(OperationRun $run, User $actor, array $data): SupportRequest
|
||||||
|
{
|
||||||
|
$run->loadMissing('tenant.workspace');
|
||||||
|
|
||||||
|
$tenant = $run->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorizeCreation($tenant, $actor);
|
||||||
|
|
||||||
|
return $this->submit(
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $actor,
|
||||||
|
data: $data,
|
||||||
|
primaryContextType: SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN,
|
||||||
|
operationRun: $run,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeCreation(Tenant $tenant, User $actor): void
|
||||||
|
{
|
||||||
|
if (! $this->capabilityResolver->isMember($actor, $tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->capabilityResolver->can($actor, $tenant, Capabilities::SUPPORT_REQUESTS_CREATE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function submit(
|
||||||
|
Tenant $tenant,
|
||||||
|
User $actor,
|
||||||
|
array $data,
|
||||||
|
string $primaryContextType,
|
||||||
|
?OperationRun $operationRun,
|
||||||
|
): SupportRequest {
|
||||||
|
$validated = $this->validate($data);
|
||||||
|
$attachDiagnosticSnapshot = $this->capabilityResolver->can($actor, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||||
|
|
||||||
|
$contextEnvelope = $operationRun instanceof OperationRun
|
||||||
|
? $this->supportRequestContextBuilder->forOperationRun($operationRun, $actor, $attachDiagnosticSnapshot)
|
||||||
|
: $this->supportRequestContextBuilder->forTenant($tenant, $actor, $attachDiagnosticSnapshot);
|
||||||
|
|
||||||
|
$contactName = $validated['contact_name'] ?? $this->normalizeNullableString($actor->name) ?? $this->normalizeNullableString($actor->email);
|
||||||
|
$contactEmail = $validated['contact_email'] ?? $this->normalizeNullableString($actor->email);
|
||||||
|
$connection = SupportRequest::query()->getModel()->getConnection();
|
||||||
|
|
||||||
|
return $connection->transaction(function () use (
|
||||||
|
$actor,
|
||||||
|
$contactEmail,
|
||||||
|
$contactName,
|
||||||
|
$contextEnvelope,
|
||||||
|
$operationRun,
|
||||||
|
$primaryContextType,
|
||||||
|
$tenant,
|
||||||
|
$validated,
|
||||||
|
): SupportRequest {
|
||||||
|
$supportRequest = SupportRequest::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
|
||||||
|
'initiated_by_user_id' => (int) $actor->getKey(),
|
||||||
|
'internal_reference' => $this->supportRequestReferenceGenerator->generate(),
|
||||||
|
'primary_context_type' => $primaryContextType,
|
||||||
|
'attachment_mode' => (string) data_get($contextEnvelope, 'attachment_mode', SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY),
|
||||||
|
'severity' => $validated['severity'],
|
||||||
|
'summary' => $validated['summary'],
|
||||||
|
'reproduction_notes' => $validated['reproduction_notes'],
|
||||||
|
'contact_name' => $contactName,
|
||||||
|
'contact_email' => $contactEmail,
|
||||||
|
'context_envelope' => $contextEnvelope,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$supportRequest->loadMissing(['tenant.workspace']);
|
||||||
|
|
||||||
|
$this->workspaceAuditLogger->logSupportRequestCreated($supportRequest, $actor);
|
||||||
|
|
||||||
|
return $supportRequest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array{
|
||||||
|
* severity: string,
|
||||||
|
* summary: string,
|
||||||
|
* reproduction_notes: ?string,
|
||||||
|
* contact_name: ?string,
|
||||||
|
* contact_email: ?string,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function validate(array $data): array
|
||||||
|
{
|
||||||
|
$validated = validator(
|
||||||
|
[
|
||||||
|
'severity' => $data['severity'] ?? SupportRequest::SEVERITY_NORMAL,
|
||||||
|
'summary' => $data['summary'] ?? null,
|
||||||
|
'reproduction_notes' => $data['reproduction_notes'] ?? null,
|
||||||
|
'contact_name' => $data['contact_name'] ?? null,
|
||||||
|
'contact_email' => $data['contact_email'] ?? null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'severity' => ['required', 'string', Rule::in(SupportRequest::severityValues())],
|
||||||
|
'summary' => ['required', 'string'],
|
||||||
|
'reproduction_notes' => ['nullable', 'string'],
|
||||||
|
'contact_name' => ['nullable', 'string'],
|
||||||
|
'contact_email' => ['nullable', 'email'],
|
||||||
|
],
|
||||||
|
)->validate();
|
||||||
|
|
||||||
|
$validated['summary'] = trim((string) $validated['summary']);
|
||||||
|
|
||||||
|
if ($validated['summary'] === '') {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'summary' => 'The summary field is required.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated['reproduction_notes'] = $this->normalizeNullableString($validated['reproduction_notes'] ?? null);
|
||||||
|
$validated['contact_name'] = $this->normalizeNullableString($validated['contact_name'] ?? null);
|
||||||
|
$validated['contact_email'] = $this->normalizeNullableString($validated['contact_email'] ?? null);
|
||||||
|
|
||||||
|
return $validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeNullableString(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim($value);
|
||||||
|
|
||||||
|
return $normalized === '' ? null : $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,6 +39,7 @@
|
|||||||
use App\Filament\System\Pages\Dashboard as SystemDashboard;
|
use App\Filament\System\Pages\Dashboard as SystemDashboard;
|
||||||
use App\Filament\System\Pages\Directory\ViewTenant as SystemDirectoryViewTenant;
|
use App\Filament\System\Pages\Directory\ViewTenant as SystemDirectoryViewTenant;
|
||||||
use App\Filament\System\Pages\Directory\ViewWorkspace as SystemDirectoryViewWorkspace;
|
use App\Filament\System\Pages\Directory\ViewWorkspace as SystemDirectoryViewWorkspace;
|
||||||
|
use App\Filament\System\Pages\Ops\Controls;
|
||||||
use App\Filament\System\Pages\Ops\Runbooks;
|
use App\Filament\System\Pages\Ops\Runbooks;
|
||||||
use App\Filament\System\Pages\Ops\ViewRun;
|
use App\Filament\System\Pages\Ops\ViewRun;
|
||||||
use App\Filament\System\Pages\RepairWorkspaceOwners;
|
use App\Filament\System\Pages\RepairWorkspaceOwners;
|
||||||
@ -661,6 +662,32 @@ public static function spec195ResidualSurfaceInventory(): array
|
|||||||
'mustRemainBaselineExempt' => false,
|
'mustRemainBaselineExempt' => false,
|
||||||
'mustNotRemainBaselineExempt' => true,
|
'mustNotRemainBaselineExempt' => true,
|
||||||
],
|
],
|
||||||
|
Controls::class => [
|
||||||
|
'surfaceKey' => 'system_ops_controls',
|
||||||
|
'surfaceName' => 'System Ops Controls',
|
||||||
|
'pageClass' => Controls::class,
|
||||||
|
'panelPlane' => 'system',
|
||||||
|
'surfaceKind' => 'system_utility',
|
||||||
|
'discoveryState' => 'outside_primary_discovery',
|
||||||
|
'closureDecision' => 'separately_governed',
|
||||||
|
'reasonCategory' => 'workflow_specific_governance',
|
||||||
|
'explicitReason' => 'Operational controls is a dedicated system control workbench with confirmation-backed pause, resume, and history actions plus restore-gate coupling, so it remains governed by focused workflow tests instead of the generic declaration-backed contract.',
|
||||||
|
'evidence' => [
|
||||||
|
[
|
||||||
|
'kind' => 'feature_livewire_test',
|
||||||
|
'reference' => 'tests/Feature/System/OpsControls/OperationalControlManagementTest.php',
|
||||||
|
'proves' => 'The controls page keeps capability-gated operational-control actions, confirmation semantics, scope previews, and audited pause or resume behavior under dedicated coverage.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'kind' => 'feature_livewire_test',
|
||||||
|
'reference' => 'tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php',
|
||||||
|
'proves' => 'Restore execution stays coupled to the shared operational-control workflow, including blocked execution and non-retroactive pause behavior after acceptance.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'followUpAction' => 'add_guard_only',
|
||||||
|
'mustRemainBaselineExempt' => false,
|
||||||
|
'mustNotRemainBaselineExempt' => true,
|
||||||
|
],
|
||||||
RepairWorkspaceOwners::class => [
|
RepairWorkspaceOwners::class => [
|
||||||
'surfaceKey' => 'repair_workspace_owners',
|
'surfaceKey' => 'repair_workspace_owners',
|
||||||
'surfaceName' => 'Repair Workspace Owners',
|
'surfaceName' => 'Repair Workspace Owners',
|
||||||
|
|||||||
98
apps/platform/database/factories/SupportRequestFactory.php
Normal file
98
apps/platform/database/factories/SupportRequestFactory.php
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SupportRequest>
|
||||||
|
*/
|
||||||
|
class SupportRequestFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = SupportRequest::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenant_id' => Tenant::factory(),
|
||||||
|
'initiated_by_user_id' => User::factory(),
|
||||||
|
'internal_reference' => 'SR-'.strtoupper((string) Str::ulid()),
|
||||||
|
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
|
||||||
|
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
|
||||||
|
'severity' => SupportRequest::SEVERITY_NORMAL,
|
||||||
|
'summary' => fake()->sentence(),
|
||||||
|
'reproduction_notes' => fake()->optional()->paragraph(),
|
||||||
|
'contact_name' => fake()->name(),
|
||||||
|
'contact_email' => fake()->safeEmail(),
|
||||||
|
'context_envelope' => [
|
||||||
|
'schema_version' => 1,
|
||||||
|
'generated_from' => 'factory',
|
||||||
|
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
|
||||||
|
'primary_context' => [
|
||||||
|
'type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
|
||||||
|
],
|
||||||
|
'canonical_context' => [
|
||||||
|
'sections' => [],
|
||||||
|
],
|
||||||
|
'diagnostic_snapshot' => [
|
||||||
|
'sections' => [],
|
||||||
|
],
|
||||||
|
'omissions' => [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canonicalContextOnly(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY,
|
||||||
|
'context_envelope' => array_replace_recursive($attributes['context_envelope'] ?? [], [
|
||||||
|
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY,
|
||||||
|
'diagnostic_snapshot' => null,
|
||||||
|
'omissions' => [[
|
||||||
|
'type' => 'diagnostic_snapshot',
|
||||||
|
'reason' => 'omitted_without_support_diagnostics_view',
|
||||||
|
]],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forOperationRun(OperationRun $operationRun): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (): array => [
|
||||||
|
'tenant_id' => (int) $operationRun->tenant_id,
|
||||||
|
'workspace_id' => (int) $operationRun->workspace_id,
|
||||||
|
'operation_run_id' => (int) $operationRun->getKey(),
|
||||||
|
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN,
|
||||||
|
'context_envelope' => [
|
||||||
|
'schema_version' => 1,
|
||||||
|
'generated_from' => 'factory',
|
||||||
|
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
|
||||||
|
'primary_context' => [
|
||||||
|
'type' => SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN,
|
||||||
|
'tenant_id' => (int) $operationRun->tenant_id,
|
||||||
|
'operation_run_id' => (int) $operationRun->getKey(),
|
||||||
|
],
|
||||||
|
'canonical_context' => [
|
||||||
|
'sections' => [],
|
||||||
|
],
|
||||||
|
'diagnostic_snapshot' => [
|
||||||
|
'sections' => [],
|
||||||
|
],
|
||||||
|
'omissions' => [],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('support_requests', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||||
|
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
|
||||||
|
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
|
||||||
|
$table->foreignId('initiated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->string('internal_reference')->unique();
|
||||||
|
$table->string('primary_context_type');
|
||||||
|
$table->string('attachment_mode');
|
||||||
|
$table->string('severity');
|
||||||
|
$table->text('summary');
|
||||||
|
$table->text('reproduction_notes')->nullable();
|
||||||
|
$table->string('contact_name')->nullable();
|
||||||
|
$table->string('contact_email')->nullable();
|
||||||
|
$table->jsonb('context_envelope')->default('{}');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['workspace_id', 'tenant_id']);
|
||||||
|
$table->index(['tenant_id', 'created_at']);
|
||||||
|
$table->index(['operation_run_id', 'created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('support_requests');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
@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,6 +6,7 @@
|
|||||||
$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)
|
||||||
@ -14,12 +15,6 @@
|
|||||||
? 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;
|
||||||
|
|
||||||
@ -52,7 +47,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="Verification report"
|
heading="Stored verification details"
|
||||||
: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')
|
||||||
@ -113,28 +108,8 @@
|
|||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($showAssist)
|
@if ($contextualHelp !== null)
|
||||||
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 shadow-sm dark:border-warning-700 dark:bg-warning-950/40">
|
@include('filament.components.product-knowledge.contextual-help', ['help' => $contextualHelp])
|
||||||
<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,6 +66,10 @@
|
|||||||
</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"
|
||||||
@ -160,3 +164,4 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,59 @@
|
|||||||
|
@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,6 +1,7 @@
|
|||||||
@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();
|
||||||
@ -32,11 +33,19 @@
|
|||||||
|
|
||||||
<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 /admin
|
Open in tenant 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,8 +1,14 @@
|
|||||||
@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();
|
||||||
|
$workspaceEntitlementSummary = $this->workspaceEntitlementSummary();
|
||||||
|
$planProfile = $workspaceEntitlementSummary['plan_profile'] ?? null;
|
||||||
|
$entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? [];
|
||||||
|
$managedTenantDecision = $entitlementDecisions['managed_tenant_activation_limit'] ?? null;
|
||||||
|
$reviewPackDecision = $entitlementDecisions['review_pack_generation_enabled'] ?? null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
@ -30,6 +36,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
|
@if ($customerHealthDecision)
|
||||||
|
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (is_array($planProfile) && is_array($managedTenantDecision) && is_array($reviewPackDecision))
|
||||||
|
<x-filament::section>
|
||||||
|
<x-slot name="heading">
|
||||||
|
Workspace entitlements
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Plan profile</p>
|
||||||
|
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $planProfile['label'] }}</p>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $planProfile['description'] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last changed</p>
|
||||||
|
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $managedTenantDecision['last_changed_by'] ?? 'Not set' }}</p>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $managedTenantDecision['last_changed_at']?->diffForHumans() ?? 'No entitlement override recorded yet.' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-950 dark:text-white">Managed tenant activation limit</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $managedTenantDecision['current_usage'] }} active of {{ $managedTenantDecision['effective_value'] }} allowed</p>
|
||||||
|
</div>
|
||||||
|
<x-filament::badge :color="$managedTenantDecision['source'] === 'workspace_override' ? 'warning' : 'gray'">
|
||||||
|
{{ $managedTenantDecision['source'] === 'workspace_override' ? 'workspace override' : ($managedTenantDecision['plan_profile_label'].' plan profile') }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $managedTenantDecision['rationale'] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-950 dark:text-white">Review pack generation</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $reviewPackDecision['effective_value'] ? 'Enabled' : 'Disabled' }}</p>
|
||||||
|
</div>
|
||||||
|
<x-filament::badge :color="$reviewPackDecision['source'] === 'workspace_override' ? 'warning' : 'gray'">
|
||||||
|
{{ $reviewPackDecision['source'] === 'workspace_override' ? 'workspace override' : ($reviewPackDecision['plan_profile_label'].' plan profile') }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $reviewPackDecision['rationale'] }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<x-slot name="heading">
|
<x-slot name="heading">
|
||||||
Tenants summary
|
Tenants summary
|
||||||
|
|||||||
@ -0,0 +1,53 @@
|
|||||||
|
<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>
|
||||||
@ -9,6 +9,8 @@
|
|||||||
/** @var ?string $pollingInterval */
|
/** @var ?string $pollingInterval */
|
||||||
/** @var bool $canView */
|
/** @var bool $canView */
|
||||||
/** @var bool $canManage */
|
/** @var bool $canManage */
|
||||||
|
/** @var bool $generationBlocked */
|
||||||
|
/** @var ?string $generationBlockReason */
|
||||||
/** @var ?string $downloadUrl */
|
/** @var ?string $downloadUrl */
|
||||||
/** @var ?string $failedReason */
|
/** @var ?string $failedReason */
|
||||||
/** @var ?string $failedReasonDetail */
|
/** @var ?string $failedReasonDetail */
|
||||||
@ -24,6 +26,12 @@
|
|||||||
@endif
|
@endif
|
||||||
>
|
>
|
||||||
<x-filament::section heading="Review Pack">
|
<x-filament::section heading="Review Pack">
|
||||||
|
@if ($canManage && $generationBlocked && $generationBlockReason)
|
||||||
|
<div class="mb-3 rounded-lg border border-warning-200 bg-warning-50 px-3 py-2 text-sm text-warning-800 dark:border-warning-500/30 dark:bg-warning-500/10 dark:text-warning-200">
|
||||||
|
{{ $generationBlockReason }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if (! $pack)
|
@if (! $pack)
|
||||||
{{-- State 1: No pack --}}
|
{{-- State 1: No pack --}}
|
||||||
<div class="flex flex-col items-center gap-3 py-4 text-center">
|
<div class="flex flex-col items-center gap-3 py-4 text-center">
|
||||||
@ -37,12 +45,15 @@
|
|||||||
size="sm"
|
size="sm"
|
||||||
wire:click="generatePack"
|
wire:click="generatePack"
|
||||||
wire:loading.attr="disabled"
|
wire:loading.attr="disabled"
|
||||||
|
:disabled="$generationBlocked"
|
||||||
>
|
>
|
||||||
Generate pack
|
Generate pack
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@elseif ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating)
|
@endif
|
||||||
|
|
||||||
|
@if ($pack && ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating))
|
||||||
{{-- State 2: Queued / Generating --}}
|
{{-- State 2: Queued / Generating --}}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@ -63,7 +74,9 @@
|
|||||||
Started {{ $pack->created_at?->diffForHumans() ?? '—' }}
|
Started {{ $pack->created_at?->diffForHumans() ?? '—' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@elseif ($statusEnum === ReviewPackStatus::Ready)
|
@endif
|
||||||
|
|
||||||
|
@if ($pack && $statusEnum === ReviewPackStatus::Ready)
|
||||||
{{-- State 3: Ready --}}
|
{{-- State 3: Ready --}}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@ -116,13 +129,16 @@
|
|||||||
color="gray"
|
color="gray"
|
||||||
wire:click="generatePack"
|
wire:click="generatePack"
|
||||||
wire:loading.attr="disabled"
|
wire:loading.attr="disabled"
|
||||||
|
:disabled="$generationBlocked"
|
||||||
>
|
>
|
||||||
Generate new
|
Generate new
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@elseif ($statusEnum === ReviewPackStatus::Failed)
|
@endif
|
||||||
|
|
||||||
|
@if ($pack && $statusEnum === ReviewPackStatus::Failed)
|
||||||
{{-- State 4: Failed --}}
|
{{-- State 4: Failed --}}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@ -163,12 +179,15 @@
|
|||||||
size="sm"
|
size="sm"
|
||||||
wire:click="generatePack"
|
wire:click="generatePack"
|
||||||
wire:loading.attr="disabled"
|
wire:loading.attr="disabled"
|
||||||
|
:disabled="$generationBlocked"
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@elseif ($statusEnum === ReviewPackStatus::Expired)
|
@endif
|
||||||
|
|
||||||
|
@if ($pack && $statusEnum === ReviewPackStatus::Expired)
|
||||||
{{-- State 5: Expired --}}
|
{{-- State 5: Expired --}}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@ -189,6 +208,7 @@
|
|||||||
size="sm"
|
size="sm"
|
||||||
wire:click="generatePack"
|
wire:click="generatePack"
|
||||||
wire:loading.attr="disabled"
|
wire:loading.attr="disabled"
|
||||||
|
:disabled="$generationBlocked"
|
||||||
>
|
>
|
||||||
Generate new
|
Generate new
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
|
|||||||
@ -5,12 +5,14 @@
|
|||||||
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
||||||
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
|
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
|
||||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||||
|
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
|
||||||
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
||||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||||
use App\Filament\Resources\Workspaces\Pages\ListWorkspaces;
|
use App\Filament\Resources\Workspaces\Pages\ListWorkspaces;
|
||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
@ -240,6 +242,47 @@ function getPlacementEmptyStateAction(Testable $component, string $name): ?Actio
|
|||||||
expect($headerCreate?->isVisible())->toBeTrue();
|
expect($headerCreate?->isVisible())->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows generate only in empty state when review packs table is empty', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(ListReviewPacks::class)
|
||||||
|
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
|
||||||
|
|
||||||
|
$emptyStateGenerate = getPlacementEmptyStateAction($component, 'generate_first');
|
||||||
|
expect($emptyStateGenerate)->not->toBeNull();
|
||||||
|
expect($emptyStateGenerate?->getLabel())->toBe('Generate first pack');
|
||||||
|
|
||||||
|
$headerGenerate = getHeaderAction($component, 'generate_pack');
|
||||||
|
expect($headerGenerate)->not->toBeNull();
|
||||||
|
expect($headerGenerate?->isVisible())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows generate only in header when review packs table is not empty', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
ReviewPack::factory()->ready()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'initiated_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = Livewire::test(ListReviewPacks::class)
|
||||||
|
->assertCountTableRecords(1);
|
||||||
|
|
||||||
|
$headerGenerate = getHeaderAction($component, 'generate_pack');
|
||||||
|
expect($headerGenerate)->not->toBeNull();
|
||||||
|
expect($headerGenerate?->isVisible())->toBeTrue();
|
||||||
|
expect($headerGenerate?->getLabel())->toBe('Generate Pack');
|
||||||
|
});
|
||||||
|
|
||||||
it('shows create only in empty state when tenants table is empty', function (): void {
|
it('shows create only in empty state when tenants table is empty', function (): void {
|
||||||
$workspace = Workspace::factory()->create([
|
$workspace = Workspace::factory()->create([
|
||||||
'archived_at' => now(),
|
'archived_at' => now(),
|
||||||
|
|||||||
@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: Workspace, 1: User}
|
||||||
|
*/
|
||||||
|
function entitlementSettingsManager(): array
|
||||||
|
{
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'manager',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
return [$workspace, $user];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('saves entitlement plan profile and override pairs through the workspace settings page', function (): void {
|
||||||
|
[$workspace, $user] = entitlementSettingsManager();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(WorkspaceSettings::getUrl(panel: 'admin'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Workspace entitlements');
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)
|
||||||
|
->test(WorkspaceSettings::class)
|
||||||
|
->assertSet('data.entitlements_plan_profile', null)
|
||||||
|
->assertSet('data.entitlements_managed_tenant_limit_override_value', null)
|
||||||
|
->assertSet('data.entitlements_managed_tenant_limit_override_reason', null)
|
||||||
|
->assertSet('data.entitlements_review_pack_generation_override_value', null)
|
||||||
|
->assertSet('data.entitlements_review_pack_generation_override_reason', null)
|
||||||
|
->set('data.entitlements_plan_profile', 'starter')
|
||||||
|
->set('data.entitlements_managed_tenant_limit_override_value', 2)
|
||||||
|
->set('data.entitlements_managed_tenant_limit_override_reason', 'Temporary support-approved exception')
|
||||||
|
->set('data.entitlements_review_pack_generation_override_value', '0')
|
||||||
|
->set('data.entitlements_review_pack_generation_override_reason', 'Workspace is temporarily limited to manual reporting only')
|
||||||
|
->callAction('save')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertSet('data.entitlements_plan_profile', 'starter')
|
||||||
|
->assertSet('data.entitlements_managed_tenant_limit_override_value', 2)
|
||||||
|
->assertSet('data.entitlements_managed_tenant_limit_override_reason', 'Temporary support-approved exception')
|
||||||
|
->assertSet('data.entitlements_review_pack_generation_override_value', '0')
|
||||||
|
->assertSet('data.entitlements_review_pack_generation_override_reason', 'Workspace is temporarily limited to manual reporting only');
|
||||||
|
|
||||||
|
$summary = app(WorkspaceEntitlementResolver::class)->summary($workspace);
|
||||||
|
|
||||||
|
expect($summary['plan_profile']['id'])->toBe('starter')
|
||||||
|
->and($summary['decisions'][WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT])
|
||||||
|
->toMatchArray([
|
||||||
|
'effective_value' => 2,
|
||||||
|
'source' => 'workspace_override',
|
||||||
|
'rationale' => 'Temporary support-approved exception',
|
||||||
|
'last_changed_by' => $user->name,
|
||||||
|
])
|
||||||
|
->and($summary['decisions'][WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED])
|
||||||
|
->toMatchArray([
|
||||||
|
'effective_value' => false,
|
||||||
|
'source' => 'workspace_override',
|
||||||
|
'rationale' => 'Workspace is temporarily limited to manual reporting only',
|
||||||
|
'last_changed_by' => $user->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component
|
||||||
|
->mountFormComponentAction('entitlements_managed_tenant_limit_override_value', 'reset_entitlements_managed_tenant_limit_override_value', [], 'content')
|
||||||
|
->callMountedFormComponentAction()
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertSet('data.entitlements_managed_tenant_limit_override_value', null)
|
||||||
|
->assertSet('data.entitlements_managed_tenant_limit_override_reason', null);
|
||||||
|
|
||||||
|
$summary = app(WorkspaceEntitlementResolver::class)->summary($workspace);
|
||||||
|
|
||||||
|
expect($summary['decisions'][WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT])
|
||||||
|
->toMatchArray([
|
||||||
|
'effective_value' => 1,
|
||||||
|
'source' => 'plan_profile_default',
|
||||||
|
'rationale' => 'Minimal allowance for early workspace access and low-volume operations.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires an override reason when a workspace entitlement override value is set', function (): void {
|
||||||
|
[, $user] = entitlementSettingsManager();
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(WorkspaceSettings::class)
|
||||||
|
->set('data.entitlements_managed_tenant_limit_override_value', 3)
|
||||||
|
->set('data.entitlements_managed_tenant_limit_override_reason', '')
|
||||||
|
->callAction('save')
|
||||||
|
->assertHasErrors(['data.entitlements_managed_tenant_limit_override_reason']);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(WorkspaceSettings::class)
|
||||||
|
->set('data.entitlements_review_pack_generation_override_value', '0')
|
||||||
|
->set('data.entitlements_review_pack_generation_override_reason', '')
|
||||||
|
->callAction('save')
|
||||||
|
->assertHasErrors(['data.entitlements_review_pack_generation_override_reason']);
|
||||||
|
});
|
||||||
@ -950,6 +950,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
\App\Filament\System\Pages\Dashboard::class,
|
\App\Filament\System\Pages\Dashboard::class,
|
||||||
\App\Filament\System\Pages\Ops\ViewRun::class,
|
\App\Filament\System\Pages\Ops\ViewRun::class,
|
||||||
\App\Filament\System\Pages\Ops\Runbooks::class,
|
\App\Filament\System\Pages\Ops\Runbooks::class,
|
||||||
|
\App\Filament\System\Pages\Ops\Controls::class,
|
||||||
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
||||||
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
||||||
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
||||||
|
|||||||
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
it('prevents ai governance surfaces from declaring direct outbound or vendor-specific provider runtime code', function (): void {
|
||||||
|
$root = app_path();
|
||||||
|
|
||||||
|
$files = collect(File::allFiles($root))
|
||||||
|
->map(fn (\SplFileInfo $file): string => str_replace($root.'/', '', $file->getPathname()))
|
||||||
|
->filter(fn (string $relativePath): bool => str_starts_with($relativePath, 'Support/Ai/')
|
||||||
|
|| $relativePath === 'Support/ProductKnowledge/ContextualHelpResolver.php'
|
||||||
|
|| $relativePath === 'Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$patterns = [
|
||||||
|
'outbound_http' => '/\bHttp::/',
|
||||||
|
'guzzle_client' => '/\bnew\s+Client\b/',
|
||||||
|
'curl_runtime' => '/\bcurl_/i',
|
||||||
|
'openai_vendor' => '/\bOpenAI\b/i',
|
||||||
|
'anthropic_vendor' => '/\bAnthropic\b/i',
|
||||||
|
'gemini_vendor' => '/\bGemini\b/i',
|
||||||
|
'openrouter_vendor' => '/\bOpenRouter\b/i',
|
||||||
|
'chat_completions_runtime' => '/\bChatCompletion\b/i',
|
||||||
|
];
|
||||||
|
|
||||||
|
$hits = [];
|
||||||
|
|
||||||
|
foreach ($files as $relativePath) {
|
||||||
|
$contents = file_get_contents($root.'/'.$relativePath);
|
||||||
|
|
||||||
|
if (! is_string($contents) || $contents === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = preg_split('/\R/', $contents) ?: [];
|
||||||
|
|
||||||
|
foreach ($patterns as $label => $pattern) {
|
||||||
|
foreach ($lines as $index => $line) {
|
||||||
|
if (preg_match($pattern, $line) === 1) {
|
||||||
|
$hits[] = $relativePath.':'.($index + 1).' ['.$label.'] '.trim($line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($hits)->toBeEmpty("AI governance surfaces must stay vendor-neutral and must not perform outbound provider runtime calls directly:\n".implode("\n", $hits));
|
||||||
|
});
|
||||||
@ -35,6 +35,7 @@ function spec195FormattedIssues(array $issues): string
|
|||||||
\App\Filament\System\Pages\Dashboard::class,
|
\App\Filament\System\Pages\Dashboard::class,
|
||||||
\App\Filament\System\Pages\Ops\ViewRun::class,
|
\App\Filament\System\Pages\Ops\ViewRun::class,
|
||||||
\App\Filament\System\Pages\Ops\Runbooks::class,
|
\App\Filament\System\Pages\Ops\Runbooks::class,
|
||||||
|
\App\Filament\System\Pages\Ops\Controls::class,
|
||||||
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
||||||
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
||||||
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
||||||
@ -67,6 +68,7 @@ function spec195FormattedIssues(array $issues): string
|
|||||||
|
|
||||||
expect($inventory[\App\Filament\System\Pages\Ops\ViewRun::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
expect($inventory[\App\Filament\System\Pages\Ops\ViewRun::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
||||||
->and($inventory[\App\Filament\System\Pages\Ops\Runbooks::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
->and($inventory[\App\Filament\System\Pages\Ops\Runbooks::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
||||||
|
->and($inventory[\App\Filament\System\Pages\Ops\Controls::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
||||||
->and($inventory[\App\Filament\System\Pages\RepairWorkspaceOwners::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
->and($inventory[\App\Filament\System\Pages\RepairWorkspaceOwners::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
||||||
->and($inventory[\App\Filament\System\Pages\Directory\ViewTenant::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
|
->and($inventory[\App\Filament\System\Pages\Directory\ViewTenant::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
|
||||||
->and($inventory[\App\Filament\System\Pages\Directory\ViewWorkspace::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
|
->and($inventory[\App\Filament\System\Pages\Directory\ViewWorkspace::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
|
||||||
@ -76,6 +78,7 @@ function spec195FormattedIssues(array $issues): string
|
|||||||
\App\Filament\System\Pages\Dashboard::class,
|
\App\Filament\System\Pages\Dashboard::class,
|
||||||
\App\Filament\System\Pages\Ops\ViewRun::class,
|
\App\Filament\System\Pages\Ops\ViewRun::class,
|
||||||
\App\Filament\System\Pages\Ops\Runbooks::class,
|
\App\Filament\System\Pages\Ops\Runbooks::class,
|
||||||
|
\App\Filament\System\Pages\Ops\Controls::class,
|
||||||
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
||||||
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
||||||
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
||||||
|
|||||||
@ -1063,7 +1063,8 @@ 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('Current checkpoint')
|
->assertSee('Step')
|
||||||
|
->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')
|
||||||
@ -1165,28 +1166,46 @@ function createManagedReadinessBlockerDraft(string $state): array
|
|||||||
->assertSee($summary)
|
->assertSee($summary)
|
||||||
->assertSee($nextAction);
|
->assertSee($nextAction);
|
||||||
})->with([
|
})->with([
|
||||||
'missing consent' => ['missing_consent', 'Provider consent required', 'Grant consent'],
|
'missing consent' => ['missing_consent', 'Provider consent required', 'Grant admin consent'],
|
||||||
'revoked consent' => ['revoked_consent', 'Provider consent revoked', 'Grant consent'],
|
'revoked consent' => ['revoked_consent', 'Provider consent revoked', 'Grant admin 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 diagnostics provider-owned while top-level readiness stays neutral', function (): void {
|
it('keeps permission gap detail out of the top-level page once a verification report is present', 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')
|
||||||
->assertSee('Permission diagnostics')
|
->assertDontSee('Permission diagnostics')
|
||||||
->assertSee('Missing application permissions')
|
->assertSee('Supporting evidence')
|
||||||
|
->assertSee('View required permissions')
|
||||||
->assertSee('Review 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')
|
||||||
|
->assertDontSee('Supporting evidence');
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
|
use App\Services\Settings\SettingsWriter;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{workspace: Workspace, user: User, tenant: Tenant, draft: TenantOnboardingSession, component: \Livewire\Features\SupportTesting\Testable}
|
||||||
|
*/
|
||||||
|
function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $limitOverride = null, ?string $overrideReason = null): array
|
||||||
|
{
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($activeTenantCount > 0) {
|
||||||
|
Tenant::factory()->count($activeTenantCount)->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'status' => Tenant::STATUS_ACTIVE,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = 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' => 'Ready connection',
|
||||||
|
'is_default' => true,
|
||||||
|
'consent_status' => 'granted',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'context' => [
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'module' => 'health_check',
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'current_step' => 'bootstrap',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_operation_run_id' => (int) $run->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($limitOverride !== null) {
|
||||||
|
$writer = app(SettingsWriter::class);
|
||||||
|
$writer->updateWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
|
||||||
|
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
|
||||||
|
value: $limitOverride,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($overrideReason !== null) {
|
||||||
|
$writer->updateWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
|
||||||
|
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
|
||||||
|
value: $overrideReason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
|
||||||
|
'onboardingDraft' => (int) $draft->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return compact('workspace', 'user', 'tenant', 'draft', 'component');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('allows onboarding activation when the workspace is within its managed tenant limit', function (): void {
|
||||||
|
$context = readyOnboardingEntitlementContext(activeTenantCount: 0);
|
||||||
|
|
||||||
|
$context['component']->call('completeOnboarding');
|
||||||
|
|
||||||
|
$context['tenant']->refresh();
|
||||||
|
|
||||||
|
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE)
|
||||||
|
->and(AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $context['workspace']->getKey())
|
||||||
|
->where('action', 'managed_tenant_onboarding.activation')
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks onboarding activation with a business-state reason when the workspace is at limit', function (): void {
|
||||||
|
$context = readyOnboardingEntitlementContext(
|
||||||
|
activeTenantCount: 1,
|
||||||
|
limitOverride: 1,
|
||||||
|
overrideReason: 'Customer currently allows one active tenant',
|
||||||
|
);
|
||||||
|
|
||||||
|
$decision = app(WorkspaceEntitlementResolver::class)->resolve(
|
||||||
|
$context['workspace'],
|
||||||
|
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($decision['is_blocked'])->toBeTrue();
|
||||||
|
|
||||||
|
$context['component']
|
||||||
|
->assertSee('Activation entitlement')
|
||||||
|
->assertSee('Blocked')
|
||||||
|
->call('completeOnboarding');
|
||||||
|
|
||||||
|
$context['tenant']->refresh();
|
||||||
|
|
||||||
|
expect($context['tenant']->status)->toBe(Tenant::STATUS_ONBOARDING)
|
||||||
|
->and(AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $context['workspace']->getKey())
|
||||||
|
->where('action', 'managed_tenant_onboarding.activation')
|
||||||
|
->exists())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows onboarding activation when a workspace override raises the limit above current usage', function (): void {
|
||||||
|
$context = readyOnboardingEntitlementContext(
|
||||||
|
activeTenantCount: 1,
|
||||||
|
limitOverride: 2,
|
||||||
|
overrideReason: 'Temporary support-approved exception',
|
||||||
|
);
|
||||||
|
|
||||||
|
$decision = app(WorkspaceEntitlementResolver::class)->resolve(
|
||||||
|
$context['workspace'],
|
||||||
|
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($decision)
|
||||||
|
->toMatchArray([
|
||||||
|
'source' => 'workspace_override',
|
||||||
|
'effective_value' => 2,
|
||||||
|
'current_usage' => 1,
|
||||||
|
'is_blocked' => false,
|
||||||
|
'rationale' => 'Temporary support-approved exception',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$context['component']->call('completeOnboarding');
|
||||||
|
|
||||||
|
$context['tenant']->refresh();
|
||||||
|
|
||||||
|
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE);
|
||||||
|
});
|
||||||
@ -0,0 +1,265 @@
|
|||||||
|
<?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();
|
||||||
|
});
|
||||||
@ -2,14 +2,17 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\System\Pages\Ops\Controls;
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\OperationalControlActivation;
|
use App\Models\OperationalControlActivation;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -131,3 +134,49 @@ function seedRestoreAuthorizationContext(): array
|
|||||||
->call('create')
|
->call('create')
|
||||||
->assertNotified('Restore execution paused');
|
->assertNotified('Restore execution paused');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forbids ai execution controls for platform users missing system panel access', function (): void {
|
||||||
|
$user = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::OPS_CONTROLS_MANAGE,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user, 'platform')
|
||||||
|
->get(Controls::getUrl(panel: 'system'))
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids ai execution controls for platform users missing ops controls manage', function (): void {
|
||||||
|
$user = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user, 'platform')
|
||||||
|
->get(Controls::getUrl(panel: 'system'))
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows ai execution controls only to platform users with the existing system control capabilities', function (): void {
|
||||||
|
$user = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::OPS_CONTROLS_MANAGE,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user, 'platform')
|
||||||
|
->get(Controls::getUrl(panel: 'system'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('AI execution');
|
||||||
|
|
||||||
|
Livewire::actingAs($user, 'platform')
|
||||||
|
->test(Controls::class)
|
||||||
|
->assertActionVisible('pause_ai_execution')
|
||||||
|
->assertActionVisible('resume_ai_execution');
|
||||||
|
});
|
||||||
@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||||
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
|
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
|
||||||
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
|
use App\Models\StoredReport;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
|
use App\Services\ReviewPackService;
|
||||||
|
use App\Services\Settings\SettingsWriter;
|
||||||
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Storage::fake('exports');
|
||||||
|
});
|
||||||
|
|
||||||
|
function seedEntitlementReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
|
||||||
|
{
|
||||||
|
StoredReport::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||||
|
'payload' => ['required_count' => 1, 'granted_count' => 1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
StoredReport::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||||
|
'payload' => ['roles' => [['displayName' => 'Global Administrator']]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->forTenant($tenant)->create();
|
||||||
|
|
||||||
|
/** @var EvidenceSnapshotService $service */
|
||||||
|
$service = app(EvidenceSnapshotService::class);
|
||||||
|
$payload = $service->buildSnapshotPayload($tenant);
|
||||||
|
|
||||||
|
$snapshot = EvidenceSnapshot::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => EvidenceSnapshotStatus::Active->value,
|
||||||
|
'fingerprint' => $payload['fingerprint'],
|
||||||
|
'completeness_state' => $payload['completeness'],
|
||||||
|
'summary' => $payload['summary'],
|
||||||
|
'generated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($payload['items'] as $item) {
|
||||||
|
$snapshot->items()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'dimension_key' => $item['dimension_key'],
|
||||||
|
'state' => $item['state'],
|
||||||
|
'required' => $item['required'],
|
||||||
|
'source_kind' => $item['source_kind'],
|
||||||
|
'source_record_type' => $item['source_record_type'],
|
||||||
|
'source_record_id' => $item['source_record_id'],
|
||||||
|
'source_fingerprint' => $item['source_fingerprint'],
|
||||||
|
'measured_at' => $item['measured_at'],
|
||||||
|
'freshness_at' => $item['freshness_at'],
|
||||||
|
'summary_payload' => $item['summary_payload'],
|
||||||
|
'sort_order' => $item['sort_order'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableReviewPackGenerationForWorkspace(Tenant $tenant, User $user, string $reason): void
|
||||||
|
{
|
||||||
|
$writer = app(SettingsWriter::class);
|
||||||
|
|
||||||
|
$writer->updateWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $tenant->workspace,
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'review_pack_generation_override_value',
|
||||||
|
value: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$writer->updateWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $tenant->workspace,
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'review_pack_generation_override_reason',
|
||||||
|
value: $reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('blocks new review pack generation before creating a review pack or operation run when the workspace is not entitled', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
seedEntitlementReviewPackSnapshot($tenant);
|
||||||
|
disableReviewPackGenerationForWorkspace($tenant, $user, 'Workspace is temporarily limited to manual reporting only');
|
||||||
|
$initialRunCount = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', OperationRunType::ReviewPackGenerate->value)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user))
|
||||||
|
->toThrow(WorkspaceEntitlementBlockedException::class, 'Workspace is temporarily limited to manual reporting only');
|
||||||
|
|
||||||
|
expect(ReviewPack::query()->count())->toBe(0)
|
||||||
|
->and(OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', OperationRunType::ReviewPackGenerate->value)
|
||||||
|
->count())->toBe($initialRunCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks executive pack export before creating a review pack or operation run when the workspace is not entitled', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$snapshot = seedEntitlementReviewPackSnapshot($tenant);
|
||||||
|
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||||
|
disableReviewPackGenerationForWorkspace($tenant, $user, 'Workspace is temporarily limited to manual reporting only');
|
||||||
|
$initialRunCount = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', OperationRunType::ReviewPackGenerate->value)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
expect(fn (): ReviewPack => app(ReviewPackService::class)->generateFromReview($review, $user))
|
||||||
|
->toThrow(WorkspaceEntitlementBlockedException::class, 'Workspace is temporarily limited to manual reporting only');
|
||||||
|
|
||||||
|
expect(ReviewPack::query()->count())->toBe(0)
|
||||||
|
->and(OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', OperationRunType::ReviewPackGenerate->value)
|
||||||
|
->count())->toBe($initialRunCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the blocked reason on the review pack card and keeps existing pack downloads accessible', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
disableReviewPackGenerationForWorkspace($tenant, $user, 'Workspace is temporarily limited to manual reporting only');
|
||||||
|
$initialRunCount = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', OperationRunType::ReviewPackGenerate->value)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
setTenantPanelContext($tenant);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||||
|
->assertSee('Workspace is temporarily limited to manual reporting only')
|
||||||
|
->assertSee('Generate pack')
|
||||||
|
->call('generatePack', true, true)
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect(ReviewPack::query()->count())->toBe(0)
|
||||||
|
->and(OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', OperationRunType::ReviewPackGenerate->value)
|
||||||
|
->count())->toBe($initialRunCount);
|
||||||
|
|
||||||
|
$filePath = 'review-packs/entitlement-download-test.zip';
|
||||||
|
Storage::disk('exports')->put($filePath, 'PK-test');
|
||||||
|
|
||||||
|
$pack = ReviewPack::factory()->ready()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'initiated_by_user_id' => (int) $user->getKey(),
|
||||||
|
'file_path' => $filePath,
|
||||||
|
'file_disk' => 'exports',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'tenant'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Download');
|
||||||
|
});
|
||||||
@ -9,11 +9,13 @@
|
|||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Support\Auth\UiTooltips;
|
use App\Support\Auth\UiTooltips;
|
||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
@ -21,6 +23,17 @@
|
|||||||
Storage::fake('exports');
|
Storage::fake('exports');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?Action
|
||||||
|
{
|
||||||
|
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
|
||||||
|
if ($action instanceof Action && $action->getName() === $name) {
|
||||||
|
return $action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Non-Member Access ───────────────────────────────────────
|
// ─── Non-Member Access ───────────────────────────────────────
|
||||||
|
|
||||||
it('returns 404 for non-member on list page', function (): void {
|
it('returns 404 for non-member on list page', function (): void {
|
||||||
@ -124,11 +137,15 @@
|
|||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
$component = Livewire::actingAs($user)
|
||||||
->test(ListReviewPacks::class)
|
->test(ListReviewPacks::class)
|
||||||
->assertActionVisible('generate_pack')
|
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
|
||||||
->assertActionDisabled('generate_pack')
|
|
||||||
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
$emptyStateAction = getReviewPackRbacEmptyStateAction($component, 'generate_first');
|
||||||
|
|
||||||
|
expect($emptyStateAction)->not->toBeNull()
|
||||||
|
->and($emptyStateAction?->isDisabled())->toBeTrue()
|
||||||
|
->and($emptyStateAction?->getTooltip())->toBe(UiTooltips::insufficientPermission());
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── REVIEW_PACK_MANAGE Member ──────────────────────────────
|
// ─── REVIEW_PACK_MANAGE Member ──────────────────────────────
|
||||||
@ -137,6 +154,12 @@
|
|||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
ReviewPack::factory()->ready()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'initiated_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
|||||||
@ -13,16 +13,19 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
|
use App\Services\Settings\SettingsWriter;
|
||||||
use App\Support\Auth\UiTooltips;
|
use App\Support\Auth\UiTooltips;
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||||
@ -31,6 +34,31 @@
|
|||||||
Storage::fake('exports');
|
Storage::fake('exports');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getReviewPackEmptyStateAction(Testable $component, string $name): ?Action
|
||||||
|
{
|
||||||
|
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
|
||||||
|
if ($action instanceof Action && $action->getName() === $name) {
|
||||||
|
return $action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReviewPackHeaderAction(Testable $component, string $name): ?Action
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
|
||||||
|
foreach ($instance->getCachedHeaderActions() as $action) {
|
||||||
|
if ($action instanceof Action && $action->getName() === $name) {
|
||||||
|
return $action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
||||||
{
|
{
|
||||||
StoredReport::factory()->create([
|
StoredReport::factory()->create([
|
||||||
@ -130,8 +158,7 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
'tenant_id' => (int) $otherTenant->getKey(),
|
'tenant_id' => (int) $otherTenant->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
setTenantPanelContext($tenant);
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListReviewPacks::class)
|
->test(ListReviewPacks::class)
|
||||||
@ -150,32 +177,112 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
->assertSee('No review packs yet');
|
->assertSee('No review packs yet');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── List Page Header Action ─────────────────────────────────
|
// ─── List Page Start CTA Placement ───────────────────────────
|
||||||
|
|
||||||
it('shows the generate_pack header action for a MANAGE user', function (): void {
|
it('shows generate only in the empty state when no review packs exist', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)
|
||||||
|
->test(ListReviewPacks::class)
|
||||||
|
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
|
||||||
|
|
||||||
|
$emptyStateAction = getReviewPackEmptyStateAction($component, 'generate_first');
|
||||||
|
$headerAction = getReviewPackHeaderAction($component, 'generate_pack');
|
||||||
|
|
||||||
|
expect($emptyStateAction)->not->toBeNull()
|
||||||
|
->and($emptyStateAction?->getLabel())->toBe('Generate first pack')
|
||||||
|
->and($headerAction)->not->toBeNull()
|
||||||
|
->and($headerAction?->isVisible())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows generate in the header once review packs exist', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
ReviewPack::factory()->ready()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'initiated_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListReviewPacks::class)
|
->test(ListReviewPacks::class)
|
||||||
->assertActionVisible('generate_pack');
|
->assertActionVisible('generate_pack');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables the generate_pack action for a readonly user', function (): void {
|
it('disables the generate_first action for a readonly user in the empty state', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
$component = Livewire::actingAs($user)
|
||||||
->test(ListReviewPacks::class)
|
->test(ListReviewPacks::class)
|
||||||
->assertActionVisible('generate_pack')
|
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
|
||||||
->assertActionDisabled('generate_pack')
|
|
||||||
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
$emptyStateAction = getReviewPackEmptyStateAction($component, 'generate_first');
|
||||||
|
|
||||||
|
expect($emptyStateAction)->not->toBeNull()
|
||||||
|
->and($emptyStateAction?->isDisabled())->toBeTrue()
|
||||||
|
->and($emptyStateAction?->getTooltip())->toBe(UiTooltips::insufficientPermission());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables review pack generation actions when the workspace entitlement blocks them', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
app(SettingsWriter::class)->updateWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $tenant->workspace,
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'review_pack_generation_override_value',
|
||||||
|
value: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
app(SettingsWriter::class)->updateWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $tenant->workspace,
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'review_pack_generation_override_reason',
|
||||||
|
value: 'Workspace is temporarily limited to manual reporting only',
|
||||||
|
);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
expect(ReviewPackResource::reviewPackGenerationActionTooltip($tenant))
|
||||||
|
->toBe('Review pack generation is disabled by workspace override. Reason: Workspace is temporarily limited to manual reporting only');
|
||||||
|
|
||||||
|
$listPage = Livewire::actingAs($user)
|
||||||
|
->test(ListReviewPacks::class)
|
||||||
|
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
|
||||||
|
|
||||||
|
$emptyStateAction = getReviewPackEmptyStateAction($listPage, 'generate_first');
|
||||||
|
$headerAction = getReviewPackHeaderAction($listPage, 'generate_pack');
|
||||||
|
|
||||||
|
expect($emptyStateAction)->not->toBeNull()
|
||||||
|
->and($emptyStateAction?->isDisabled())->toBeTrue()
|
||||||
|
->and($headerAction)->not->toBeNull()
|
||||||
|
->and($headerAction?->isVisible())->toBeFalse();
|
||||||
|
|
||||||
|
$pack = ReviewPack::factory()->ready()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'initiated_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
|
||||||
|
->assertActionVisible('regenerate')
|
||||||
|
->assertActionDisabled('regenerate');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reuses an existing ready pack instead of starting a new run', function (): void {
|
it('reuses an existing ready pack instead of starting a new run', function (): void {
|
||||||
@ -225,6 +332,12 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
ReviewPack::factory()->failed()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'initiated_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
@ -236,7 +349,7 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
])
|
])
|
||||||
->assertNotified();
|
->assertNotified();
|
||||||
|
|
||||||
expect(ReviewPack::query()->count())->toBe(0);
|
expect(ReviewPack::query()->count())->toBe(1);
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,9 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@ -48,7 +51,13 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
|
|||||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
OperationRun::factory()->forTenant($tenant)->create();
|
OperationRun::factory()->forTenant($tenant)->create([
|
||||||
|
'type' => OperationRunType::TenantReviewCompose->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'started_at' => now()->subMinute(),
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
/** @var EvidenceSnapshotService $service */
|
/** @var EvidenceSnapshotService $service */
|
||||||
$service = app(EvidenceSnapshotService::class);
|
$service = app(EvidenceSnapshotService::class);
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Settings\SettingsResolver;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: Workspace, 1: User}
|
||||||
|
*/
|
||||||
|
function workspaceAiPolicyManager(): array
|
||||||
|
{
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'manager',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
return [$workspace, $user];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders the workspace ai policy section and lets managers save and reset the ai posture', function (): void {
|
||||||
|
[$workspace, $user] = workspaceAiPolicyManager();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(WorkspaceSettings::getUrl(panel: 'admin'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Workspace AI policy')
|
||||||
|
->assertSee('Disabled')
|
||||||
|
->assertSee('Private only')
|
||||||
|
->assertSee('Approved use cases')
|
||||||
|
->assertSee('Blocked data classifications');
|
||||||
|
|
||||||
|
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
|
||||||
|
->toBe('disabled');
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)
|
||||||
|
->test(WorkspaceSettings::class)
|
||||||
|
->assertSet('data.ai_policy_mode', null)
|
||||||
|
->set('data.ai_policy_mode', 'private_only')
|
||||||
|
->callAction('save')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertSet('data.ai_policy_mode', 'private_only');
|
||||||
|
|
||||||
|
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
|
||||||
|
->toBe('private_only');
|
||||||
|
|
||||||
|
$component
|
||||||
|
->mountFormComponentAction('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
|
||||||
|
->callMountedFormComponentAction()
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertSet('data.ai_policy_mode', null);
|
||||||
|
|
||||||
|
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
|
||||||
|
->toBe('disabled');
|
||||||
|
});
|
||||||
@ -79,3 +79,76 @@
|
|||||||
->and(data_get($audit?->metadata, 'before_value'))->toBe(48)
|
->and(data_get($audit?->metadata, 'before_value'))->toBe(48)
|
||||||
->and(data_get($audit?->metadata, 'after_value'))->toBe(30);
|
->and(data_get($audit?->metadata, 'after_value'))->toBe(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('writes a workspace-scoped audit entry when ai policy mode is updated', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'manager',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(SettingsWriter::class)->updateWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: 'ai',
|
||||||
|
key: 'policy_mode',
|
||||||
|
value: 'private_only',
|
||||||
|
);
|
||||||
|
|
||||||
|
$audit = AuditLog::query()->latest('id')->first();
|
||||||
|
|
||||||
|
expect($audit)->not->toBeNull()
|
||||||
|
->and($audit?->workspace_id)->toBe((int) $workspace->getKey())
|
||||||
|
->and($audit?->tenant_id)->toBeNull()
|
||||||
|
->and($audit?->action)->toBe(AuditActionId::WorkspaceSettingUpdated->value)
|
||||||
|
->and(data_get($audit?->metadata, 'domain'))->toBe('ai')
|
||||||
|
->and(data_get($audit?->metadata, 'key'))->toBe('policy_mode')
|
||||||
|
->and(data_get($audit?->metadata, 'scope'))->toBe('workspace')
|
||||||
|
->and(data_get($audit?->metadata, 'before_value'))->toBeNull()
|
||||||
|
->and(data_get($audit?->metadata, 'after_value'))->toBe('private_only');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a workspace-scoped audit entry when ai policy mode is reset', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'manager',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$writer = app(SettingsWriter::class);
|
||||||
|
|
||||||
|
$writer->updateWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: 'ai',
|
||||||
|
key: 'policy_mode',
|
||||||
|
value: 'private_only',
|
||||||
|
);
|
||||||
|
|
||||||
|
$writer->resetWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: 'ai',
|
||||||
|
key: 'policy_mode',
|
||||||
|
);
|
||||||
|
|
||||||
|
$audit = AuditLog::query()
|
||||||
|
->where('action', AuditActionId::WorkspaceSettingReset->value)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($audit)->not->toBeNull()
|
||||||
|
->and($audit?->workspace_id)->toBe((int) $workspace->getKey())
|
||||||
|
->and($audit?->tenant_id)->toBeNull()
|
||||||
|
->and(data_get($audit?->metadata, 'domain'))->toBe('ai')
|
||||||
|
->and(data_get($audit?->metadata, 'key'))->toBe('policy_mode')
|
||||||
|
->and(data_get($audit?->metadata, 'scope'))->toBe('workspace')
|
||||||
|
->and(data_get($audit?->metadata, 'before_value'))->toBe('private_only')
|
||||||
|
->and(data_get($audit?->metadata, 'after_value'))->toBe('disabled');
|
||||||
|
});
|
||||||
|
|||||||
@ -44,6 +44,7 @@ function workspaceManagerUser(): array
|
|||||||
|
|
||||||
$component = Livewire::actingAs($user)
|
$component = Livewire::actingAs($user)
|
||||||
->test(WorkspaceSettings::class)
|
->test(WorkspaceSettings::class)
|
||||||
|
->assertSet('data.ai_policy_mode', null)
|
||||||
->assertSet('data.backup_retention_keep_last_default', null)
|
->assertSet('data.backup_retention_keep_last_default', null)
|
||||||
->assertSet('data.backup_retention_min_floor', null)
|
->assertSet('data.backup_retention_min_floor', null)
|
||||||
->assertSet('data.drift_severity_mapping', [])
|
->assertSet('data.drift_severity_mapping', [])
|
||||||
@ -58,6 +59,7 @@ function workspaceManagerUser(): array
|
|||||||
->assertSet('data.findings_sla_low', null)
|
->assertSet('data.findings_sla_low', null)
|
||||||
->assertSet('data.operations_operation_run_retention_days', null)
|
->assertSet('data.operations_operation_run_retention_days', null)
|
||||||
->assertSet('data.operations_stuck_run_threshold_minutes', null)
|
->assertSet('data.operations_stuck_run_threshold_minutes', null)
|
||||||
|
->set('data.ai_policy_mode', 'private_only')
|
||||||
->set('data.backup_retention_keep_last_default', 55)
|
->set('data.backup_retention_keep_last_default', 55)
|
||||||
->set('data.backup_retention_min_floor', 12)
|
->set('data.backup_retention_min_floor', 12)
|
||||||
->set('data.drift_severity_mapping', ['drift' => 'critical'])
|
->set('data.drift_severity_mapping', ['drift' => 'critical'])
|
||||||
@ -74,6 +76,7 @@ function workspaceManagerUser(): array
|
|||||||
->set('data.operations_stuck_run_threshold_minutes', 60)
|
->set('data.operations_stuck_run_threshold_minutes', 60)
|
||||||
->callAction('save')
|
->callAction('save')
|
||||||
->assertHasNoErrors()
|
->assertHasNoErrors()
|
||||||
|
->assertSet('data.ai_policy_mode', 'private_only')
|
||||||
->assertSet('data.backup_retention_keep_last_default', 55)
|
->assertSet('data.backup_retention_keep_last_default', 55)
|
||||||
->assertSet('data.backup_retention_min_floor', 12)
|
->assertSet('data.backup_retention_min_floor', 12)
|
||||||
->assertSet('data.baseline_severity_missing_policy', 'critical')
|
->assertSet('data.baseline_severity_missing_policy', 'critical')
|
||||||
@ -97,6 +100,9 @@ function workspaceManagerUser(): array
|
|||||||
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default'))
|
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default'))
|
||||||
->toBe(55);
|
->toBe(55);
|
||||||
|
|
||||||
|
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
|
||||||
|
->toBe('private_only');
|
||||||
|
|
||||||
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_min_floor'))
|
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_min_floor'))
|
||||||
->toBe(12);
|
->toBe(12);
|
||||||
|
|
||||||
@ -142,6 +148,18 @@ function workspaceManagerUser(): array
|
|||||||
->where('key', 'retention_keep_last_default')
|
->where('key', 'retention_keep_last_default')
|
||||||
->exists())->toBeFalse();
|
->exists())->toBeFalse();
|
||||||
|
|
||||||
|
$component
|
||||||
|
->mountFormComponentAction('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
|
||||||
|
->callMountedFormComponentAction()
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertSet('data.ai_policy_mode', null);
|
||||||
|
|
||||||
|
expect(WorkspaceSetting::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('domain', 'ai')
|
||||||
|
->where('key', 'policy_mode')
|
||||||
|
->exists())->toBeFalse();
|
||||||
|
|
||||||
$component
|
$component
|
||||||
->mountFormComponentAction('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content')
|
->mountFormComponentAction('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content')
|
||||||
->callMountedFormComponentAction()
|
->callMountedFormComponentAction()
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceSetting;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
@ -12,6 +13,14 @@
|
|||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceSetting::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'domain' => 'ai',
|
||||||
|
'key' => 'policy_mode',
|
||||||
|
'value' => 'private_only',
|
||||||
|
'updated_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
|
|||||||
@ -30,6 +30,14 @@
|
|||||||
'updated_by_user_id' => null,
|
'updated_by_user_id' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
WorkspaceSetting::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'domain' => 'ai',
|
||||||
|
'key' => 'policy_mode',
|
||||||
|
'value' => 'private_only',
|
||||||
|
'updated_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
@ -38,6 +46,7 @@
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(WorkspaceSettings::class)
|
->test(WorkspaceSettings::class)
|
||||||
|
->assertSet('data.ai_policy_mode', 'private_only')
|
||||||
->assertSet('data.backup_retention_keep_last_default', 27)
|
->assertSet('data.backup_retention_keep_last_default', 27)
|
||||||
->assertSet('data.backup_retention_min_floor', null)
|
->assertSet('data.backup_retention_min_floor', null)
|
||||||
->assertSet('data.drift_severity_mapping', [])
|
->assertSet('data.drift_severity_mapping', [])
|
||||||
@ -56,6 +65,8 @@
|
|||||||
->assertActionDisabled('save')
|
->assertActionDisabled('save')
|
||||||
->assertFormComponentActionVisible('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content')
|
->assertFormComponentActionVisible('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content')
|
||||||
->assertFormComponentActionDisabled('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content')
|
->assertFormComponentActionDisabled('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content')
|
||||||
|
->assertFormComponentActionVisible('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
|
||||||
|
->assertFormComponentActionDisabled('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
|
||||||
->assertFormComponentActionVisible('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
|
->assertFormComponentActionVisible('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
|
||||||
->assertFormComponentActionDisabled('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
|
->assertFormComponentActionDisabled('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
|
||||||
->assertFormComponentActionVisible('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content')
|
->assertFormComponentActionVisible('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content')
|
||||||
@ -75,6 +86,11 @@
|
|||||||
->call('save')
|
->call('save')
|
||||||
->assertStatus(403);
|
->assertStatus(403);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(WorkspaceSettings::class)
|
||||||
|
->call('resetSetting', 'ai_policy_mode')
|
||||||
|
->assertStatus(403);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(WorkspaceSettings::class)
|
->test(WorkspaceSettings::class)
|
||||||
->call('resetSetting', 'backup_retention_keep_last_default')
|
->call('resetSetting', 'backup_retention_keep_last_default')
|
||||||
@ -88,5 +104,12 @@
|
|||||||
->where('key', 'retention_keep_last_default')
|
->where('key', 'retention_keep_last_default')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
expect($setting)->not->toBeNull();
|
$aiSetting = WorkspaceSetting::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('domain', 'ai')
|
||||||
|
->where('key', 'policy_mode')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($setting)->not->toBeNull()
|
||||||
|
->and($aiSetting)->not->toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,133 @@
|
|||||||
|
<?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();
|
||||||
|
});
|
||||||
@ -0,0 +1,162 @@
|
|||||||
|
<?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],
|
||||||
|
]);
|
||||||
@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
function operationSupportRequestComponent(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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationSupportRequestHeaderActions(\Livewire\Features\SupportTesting\Testable $component): array
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
|
||||||
|
if ($instance->getCachedHeaderActions() === []) {
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->getCachedHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationSupportRequestHeaderPrimaryNames(\Livewire\Features\SupportTesting\Testable $component): array
|
||||||
|
{
|
||||||
|
return collect(operationSupportRequestHeaderActions($component))
|
||||||
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationSupportRequestHeaderMoreActionNames(\Livewire\Features\SupportTesting\Testable $component): array
|
||||||
|
{
|
||||||
|
$moreGroup = collect(operationSupportRequestHeaderActions($component))
|
||||||
|
->first(static fn ($action): bool => $action instanceof ActionGroup && $action->getLabel() === 'More');
|
||||||
|
|
||||||
|
return collect($moreGroup?->getActions() ?? [])
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationSupportRequestHeaderMoreAction(\Livewire\Features\SupportTesting\Testable $component, string $name): ?Action
|
||||||
|
{
|
||||||
|
$moreGroup = collect(operationSupportRequestHeaderActions($component))
|
||||||
|
->first(static fn ($action): bool => $action instanceof ActionGroup && $action->getLabel() === 'More');
|
||||||
|
|
||||||
|
$action = collect($moreGroup?->getActions() ?? [])
|
||||||
|
->first(static fn ($action): bool => $action instanceof Action && $action->getName() === $name);
|
||||||
|
|
||||||
|
return $action instanceof Action ? $action : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates a run-scoped support request from the tenantless operation viewer', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()
|
||||||
|
->forTenant($tenant)
|
||||||
|
->create([
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => 0,
|
||||||
|
'processed' => 0,
|
||||||
|
],
|
||||||
|
'failure_summary' => [[
|
||||||
|
'message' => 'Run failed after provider validation.',
|
||||||
|
]],
|
||||||
|
'completed_at' => now()->subMinutes(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = operationSupportRequestComponent($user, $run);
|
||||||
|
|
||||||
|
expect(operationSupportRequestHeaderPrimaryNames($component))
|
||||||
|
->not->toContain('openSupportDiagnostics')
|
||||||
|
->not->toContain('requestSupport')
|
||||||
|
->and(operationSupportRequestHeaderMoreActionNames($component))
|
||||||
|
->toEqualCanonicalizing(['openSupportDiagnostics', 'requestSupport'])
|
||||||
|
->and(operationSupportRequestHeaderMoreAction($component, 'openSupportDiagnostics')?->isIconButton())
|
||||||
|
->toBeFalse();
|
||||||
|
|
||||||
|
$component
|
||||||
|
->assertActionVisible('openSupportDiagnostics')
|
||||||
|
->assertActionEnabled('openSupportDiagnostics')
|
||||||
|
->assertActionVisible('requestSupport')
|
||||||
|
->assertActionEnabled('requestSupport')
|
||||||
|
->assertActionExists('requestSupport', fn (Action $action): bool => $action->getLabel() === 'Request support')
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_BLOCKING,
|
||||||
|
'summary' => 'This failed operation needs support escalation.',
|
||||||
|
'reproduction_notes' => 'Open the canonical run detail and submit the request from the grouped secondary action.',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Support request submitted');
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
expect($supportRequest->internal_reference)->toMatch('/^SR-[0-9A-HJKMNP-TV-Z]{26}$/')
|
||||||
|
->and($supportRequest->workspace_id)->toBe((int) $tenant->workspace_id)
|
||||||
|
->and($supportRequest->tenant_id)->toBe((int) $tenant->getKey())
|
||||||
|
->and($supportRequest->initiated_by_user_id)->toBe((int) $user->getKey())
|
||||||
|
->and($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
|
||||||
|
->and($supportRequest->operation_run_id)->toBe((int) $run->getKey())
|
||||||
|
->and($supportRequest->attachment_mode)->toBe(SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED)
|
||||||
|
->and($supportRequest->severity)->toBe(SupportRequest::SEVERITY_BLOCKING)
|
||||||
|
->and($supportRequest->summary)->toBe('This failed operation needs support escalation.')
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'primary_context.type'))->toBe('operation_run')
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'primary_context.operation_run_id'))->toBe((int) $run->getKey())
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'diagnostic_snapshot'))->toBeArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps tenantless operation detail 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();
|
||||||
|
});
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
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;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
function supportRequestAuditTenantComponent(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 supportRequestAuditOperationComponent(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('records a redacted audit entry for tenant-scoped support requests', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create(['name' => 'Audit Support Tenant']);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
ProviderConnection::factory()
|
||||||
|
->withCredential()
|
||||||
|
->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'display_name' => 'Audit Microsoft connection',
|
||||||
|
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
||||||
|
'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
|
||||||
|
'last_error_message' => 'tenant-provider-secret',
|
||||||
|
]);
|
||||||
|
|
||||||
|
supportRequestAuditTenantComponent($user, $tenant)
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_HIGH,
|
||||||
|
'summary' => 'Need tenant support audit proof.',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
$audit = AuditLog::query()
|
||||||
|
->where('action', AuditActionId::SupportRequestCreated->value)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($audit)->not->toBeNull()
|
||||||
|
->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id)
|
||||||
|
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
|
||||||
|
->and($audit?->resource_type)->toBe('support_request')
|
||||||
|
->and($audit?->resource_id)->toBe((string) $supportRequest->getKey())
|
||||||
|
->and($audit?->target_label)->toBe($supportRequest->internal_reference)
|
||||||
|
->and($audit?->operation_run_id)->toBeNull()
|
||||||
|
->and(data_get($audit?->metadata, 'internal_reference'))->toBe($supportRequest->internal_reference)
|
||||||
|
->and(data_get($audit?->metadata, 'primary_context_type'))->toBe(SupportRequest::PRIMARY_CONTEXT_TENANT)
|
||||||
|
->and(data_get($audit?->metadata, 'primary_context_id'))->toBe((string) $tenant->getKey())
|
||||||
|
->and(data_get($audit?->metadata, 'attachment_mode'))->toBe(SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED)
|
||||||
|
->and(data_get($audit?->metadata, 'redaction_mode'))->toBe('default_redacted')
|
||||||
|
->and((string) json_encode($audit?->metadata))->not->toContain('tenant-provider-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a redacted audit entry for run-scoped support requests', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
||||||
|
|
||||||
|
$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::Failed->value,
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => 0,
|
||||||
|
'processed' => 0,
|
||||||
|
],
|
||||||
|
'context' => [
|
||||||
|
'raw_response_body' => 'run-provider-secret',
|
||||||
|
],
|
||||||
|
'failure_summary' => [[
|
||||||
|
'message' => 'Run failed after provider validation.',
|
||||||
|
]],
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
supportRequestAuditOperationComponent($user, $run)
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_BLOCKING,
|
||||||
|
'summary' => 'Need run support audit proof.',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
$audit = AuditLog::query()
|
||||||
|
->where('action', AuditActionId::SupportRequestCreated->value)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($audit)->not->toBeNull()
|
||||||
|
->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id)
|
||||||
|
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
|
||||||
|
->and($audit?->resource_type)->toBe('support_request')
|
||||||
|
->and($audit?->resource_id)->toBe((string) $supportRequest->getKey())
|
||||||
|
->and($audit?->target_label)->toBe($supportRequest->internal_reference)
|
||||||
|
->and($audit?->operation_run_id)->toBe((int) $run->getKey())
|
||||||
|
->and(data_get($audit?->metadata, 'internal_reference'))->toBe($supportRequest->internal_reference)
|
||||||
|
->and(data_get($audit?->metadata, 'primary_context_type'))->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
|
||||||
|
->and(data_get($audit?->metadata, 'primary_context_id'))->toBe((string) $run->getKey())
|
||||||
|
->and(data_get($audit?->metadata, 'attachment_mode'))->toBe(SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED)
|
||||||
|
->and(data_get($audit?->metadata, 'redaction_mode'))->toBe('default_redacted')
|
||||||
|
->and((string) json_encode($audit?->metadata))->not->toContain('run-provider-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates distinct support references for duplicate submissions without outbound http or operation-run side effects', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
||||||
|
|
||||||
|
$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::Failed->value,
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => 0,
|
||||||
|
'processed' => 0,
|
||||||
|
],
|
||||||
|
'failure_summary' => [[
|
||||||
|
'message' => 'Run failed after provider validation.',
|
||||||
|
]],
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = supportRequestAuditOperationComponent($user, $run);
|
||||||
|
$existingRunCount = OperationRun::query()->count();
|
||||||
|
|
||||||
|
assertNoOutboundHttp(function () use ($component): void {
|
||||||
|
$component
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_HIGH,
|
||||||
|
'summary' => 'Duplicate run support request.',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_HIGH,
|
||||||
|
'summary' => 'Duplicate run support request.',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
$supportRequests = SupportRequest::query()
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$auditReferences = AuditLog::query()
|
||||||
|
->where('action', AuditActionId::SupportRequestCreated->value)
|
||||||
|
->orderBy('id')
|
||||||
|
->pluck('target_label');
|
||||||
|
|
||||||
|
expect($supportRequests)->toHaveCount(2)
|
||||||
|
->and($supportRequests->pluck('summary')->all())->toBe([
|
||||||
|
'Duplicate run support request.',
|
||||||
|
'Duplicate run support request.',
|
||||||
|
])
|
||||||
|
->and($supportRequests->pluck('internal_reference')->unique())->toHaveCount(2)
|
||||||
|
->and($supportRequests->pluck('operation_run_id')->unique()->all())->toBe([(int) $run->getKey()])
|
||||||
|
->and($auditReferences->all())->toBe($supportRequests->pluck('internal_reference')->all())
|
||||||
|
->and(OperationRun::query()->count())->toBe($existingRunCount)
|
||||||
|
->and($run->fresh()?->status)->toBe(OperationRunStatus::Completed->value)
|
||||||
|
->and($run->fresh()?->outcome)->toBe(OperationRunOutcome::Failed->value);
|
||||||
|
});
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
function supportRequestAuthorizationTenantComponent(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 supportRequestAuthorizationOperationComponent(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('returns forbidden for entitled tenant members without support request capability', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||||
|
|
||||||
|
supportRequestAuthorizationTenantComponent($user, $tenant)
|
||||||
|
->assertActionVisible('requestSupport')
|
||||||
|
->assertActionDisabled('requestSupport')
|
||||||
|
->call('authorizeTenantSupportRequest')
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
expect(SupportRequest::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns forbidden for entitled run viewers without support request capability', 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,
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => 0,
|
||||||
|
'processed' => 0,
|
||||||
|
],
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
supportRequestAuthorizationOperationComponent($user, $run)
|
||||||
|
->assertActionVisible('requestSupport')
|
||||||
|
->assertActionDisabled('requestSupport')
|
||||||
|
->call('authorizeOperationRunSupportRequest')
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
expect(SupportRequest::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
use function Pest\Laravel\mock;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
function tenantSupportRequestComponent(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates a tenant support request from the dashboard', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
tenantSupportRequestComponent($user, $tenant)
|
||||||
|
->assertActionVisible('requestSupport')
|
||||||
|
->assertActionEnabled('requestSupport')
|
||||||
|
->assertActionExists('requestSupport', fn (Action $action): bool => $action->getLabel() === 'Request support')
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_HIGH,
|
||||||
|
'summary' => 'Policy sync failed after the latest tenant refresh.',
|
||||||
|
'reproduction_notes' => 'Open the tenant dashboard after a failed sync and request support from the header action.',
|
||||||
|
'contact_name' => 'Ops On Call',
|
||||||
|
'contact_email' => 'ops@example.test',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Support request submitted');
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
expect($supportRequest->internal_reference)->toMatch('/^SR-[0-9A-HJKMNP-TV-Z]{26}$/')
|
||||||
|
->and($supportRequest->workspace_id)->toBe((int) $tenant->workspace_id)
|
||||||
|
->and($supportRequest->tenant_id)->toBe((int) $tenant->getKey())
|
||||||
|
->and($supportRequest->initiated_by_user_id)->toBe((int) $user->getKey())
|
||||||
|
->and($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_TENANT)
|
||||||
|
->and($supportRequest->operation_run_id)->toBeNull()
|
||||||
|
->and($supportRequest->attachment_mode)->toBe(SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED)
|
||||||
|
->and($supportRequest->severity)->toBe(SupportRequest::SEVERITY_HIGH)
|
||||||
|
->and($supportRequest->summary)->toBe('Policy sync failed after the latest tenant refresh.')
|
||||||
|
->and($supportRequest->reproduction_notes)->toContain('failed sync')
|
||||||
|
->and($supportRequest->contact_name)->toBe('Ops On Call')
|
||||||
|
->and($supportRequest->contact_email)->toBe('ops@example.test')
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'primary_context.type'))->toBe('tenant')
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'primary_context.tenant_id'))->toBe((int) $tenant->getKey())
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'diagnostic_snapshot'))->toBeArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores canonical context only when the creator cannot view support diagnostics', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
|
||||||
|
$mock->shouldReceive('primeMemberships')->andReturnNull();
|
||||||
|
$mock->shouldReceive('isMember')
|
||||||
|
->andReturnUsing(static fn ($user, Tenant $resolvedTenant): bool => (int) $resolvedTenant->getKey() === (int) $tenant->getKey());
|
||||||
|
|
||||||
|
$mock->shouldReceive('can')
|
||||||
|
->andReturnUsing(static function ($user, Tenant $resolvedTenant, string $capability) use ($tenant): bool {
|
||||||
|
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
|
||||||
|
|
||||||
|
return match ($capability) {
|
||||||
|
Capabilities::SUPPORT_REQUESTS_CREATE => true,
|
||||||
|
Capabilities::SUPPORT_DIAGNOSTICS_VIEW => false,
|
||||||
|
default => true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tenantSupportRequestComponent($user, $tenant)
|
||||||
|
->assertActionVisible('requestSupport')
|
||||||
|
->assertActionEnabled('requestSupport')
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'summary' => 'Need help reviewing the latest tenant support context.',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Support request submitted');
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
expect($supportRequest->severity)->toBe(SupportRequest::SEVERITY_NORMAL)
|
||||||
|
->and($supportRequest->contact_name)->toBe($user->name)
|
||||||
|
->and($supportRequest->contact_email)->toBe($user->email)
|
||||||
|
->and($supportRequest->attachment_mode)->toBe(SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY)
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'diagnostic_snapshot'))->toBeNull()
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'omissions.0.reason'))->toBe('omitted_without_support_diagnostics_view');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps tenant dashboard support requests 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 tenant members without support request capability', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||||
|
|
||||||
|
tenantSupportRequestComponent($user, $tenant)
|
||||||
|
->assertActionVisible('requestSupport')
|
||||||
|
->assertActionDisabled('requestSupport')
|
||||||
|
->call('authorizeTenantSupportRequest')
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
expect(SupportRequest::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
<?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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@ -0,0 +1,186 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -0,0 +1,158 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -0,0 +1,177 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\System\Pages\Ops\Controls;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\OperationalControlActivation;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Filament::setCurrentPanel('system');
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeAiControlsManager(): PlatformUser
|
||||||
|
{
|
||||||
|
return PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::OPS_CONTROLS_MANAGE,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('pauses and resumes ai execution through the global-only controls card', function (): void {
|
||||||
|
$workspaceA = Workspace::factory()->create(['name' => 'Acme']);
|
||||||
|
$workspaceB = Workspace::factory()->create(['name' => 'Bravo']);
|
||||||
|
|
||||||
|
Tenant::factory()->count(2)->create(['workspace_id' => (int) $workspaceA->getKey()]);
|
||||||
|
Tenant::factory()->count(1)->create(['workspace_id' => (int) $workspaceB->getKey()]);
|
||||||
|
|
||||||
|
$user = makeAiControlsManager();
|
||||||
|
$this->actingAs($user, 'platform');
|
||||||
|
|
||||||
|
$this->get(Controls::getUrl(panel: 'system'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee("mountAction('pause_ai_execution')", escape: false);
|
||||||
|
|
||||||
|
$component = Livewire::test(Controls::class)
|
||||||
|
->assertActionExists('pause_ai_execution', fn (Action $action): bool => $action->isConfirmationRequired())
|
||||||
|
->assertActionExists('resume_ai_execution', fn (Action $action): bool => $action->isConfirmationRequired())
|
||||||
|
->assertActionExists('view_history_ai_execution', fn (Action $action): bool => $action->getLabel() === 'View AI execution history');
|
||||||
|
|
||||||
|
$summary = $component->instance()->controlSummary('ai.execution');
|
||||||
|
$preview = $component->instance()->scopeImpactPreview('ai.execution', 'global', null);
|
||||||
|
|
||||||
|
expect($summary['label'])->toBe('AI execution')
|
||||||
|
->and($summary['supported_scopes'])->toBe(['global'])
|
||||||
|
->and($summary['effective_state'])->toBe('enabled')
|
||||||
|
->and($preview['summary'])->toContain('AI execution')
|
||||||
|
->and($preview['workspace_count'])->toBe(2)
|
||||||
|
->and($preview['tenant_count'])->toBe(3);
|
||||||
|
|
||||||
|
$component
|
||||||
|
->callAction('pause_ai_execution', data: [
|
||||||
|
'scope_type' => 'global',
|
||||||
|
'reason_text' => 'Paused for AI rollout review.',
|
||||||
|
'expires_at' => now()->addDay()->toDateTimeString(),
|
||||||
|
])
|
||||||
|
->assertNotified('AI execution paused');
|
||||||
|
|
||||||
|
$activation = OperationalControlActivation::query()
|
||||||
|
->forControl('ai.execution')
|
||||||
|
->forGlobalScope()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($activation)->not->toBeNull()
|
||||||
|
->and($activation?->reason_text)->toBe('Paused for AI rollout review.');
|
||||||
|
|
||||||
|
$pausedSummary = $component->instance()->controlSummary('ai.execution');
|
||||||
|
|
||||||
|
expect($pausedSummary['effective_state'])->toBe('paused')
|
||||||
|
->and($pausedSummary['state_label'])->toBe('Paused globally');
|
||||||
|
|
||||||
|
$component
|
||||||
|
->callAction('resume_ai_execution', data: [
|
||||||
|
'scope_type' => 'global',
|
||||||
|
])
|
||||||
|
->assertNotified('AI execution resumed');
|
||||||
|
|
||||||
|
expect(OperationalControlActivation::query()
|
||||||
|
->forControl('ai.execution')
|
||||||
|
->forGlobalScope()
|
||||||
|
->count())->toBe(0);
|
||||||
|
|
||||||
|
$audits = AuditLog::query()
|
||||||
|
->whereIn('action', [
|
||||||
|
AuditActionId::OperationalControlPaused->value,
|
||||||
|
AuditActionId::OperationalControlResumed->value,
|
||||||
|
])
|
||||||
|
->where('metadata->control_key', 'ai.execution')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
expect($audits)->toHaveCount(2)
|
||||||
|
->and($audits[0]->workspace_id)->toBeNull()
|
||||||
|
->and($audits[1]->workspace_id)->toBeNull();
|
||||||
|
});
|
||||||
@ -6,6 +6,8 @@
|
|||||||
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;
|
||||||
@ -72,17 +74,23 @@
|
|||||||
|
|
||||||
$widgets = $component->instance()->getWidgets();
|
$widgets = $component->instance()->getWidgets();
|
||||||
|
|
||||||
expect($widgets)->toHaveCount(5)
|
expect($widgets)->toHaveCount(7)
|
||||||
->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[1]->widget)->toBe(ControlTowerKpis::class)
|
->and($widgets[5])->toBeInstanceOf(WidgetConfiguration::class)
|
||||||
->and($widgets[2]->widget)->toBe(ProductTelemetryKpis::class)
|
->and($widgets[6])->toBeInstanceOf(WidgetConfiguration::class)
|
||||||
->and($widgets[3]->widget)->toBe(ControlTowerTopOffenders::class)
|
->and($widgets[1]->widget)->toBe(CustomerHealthKpis::class)
|
||||||
->and($widgets[4]->widget)->toBe(ControlTowerRecentFailures::class)
|
->and($widgets[2]->widget)->toBe(CustomerHealthTopWorkspaces::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,7 +76,8 @@
|
|||||||
->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 /admin')
|
->assertSee('Open in tenant 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)
|
||||||
|
|||||||
@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\System\Pages\Directory\ViewWorkspace;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Settings\SettingsWriter;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
|
||||||
|
it('renders the read-only workspace entitlement summary on the system workspace detail page', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create(['name' => 'Acme Workspace']);
|
||||||
|
$manager = User::factory()->create(['name' => 'Workspace Manager']);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $manager->getKey(),
|
||||||
|
'role' => 'manager',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Tenant::factory()->count(2)->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'status' => Tenant::STATUS_ACTIVE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$writer = app(SettingsWriter::class);
|
||||||
|
$writer->updateWorkspaceSetting(
|
||||||
|
actor: $manager,
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'plan_profile',
|
||||||
|
value: 'starter',
|
||||||
|
);
|
||||||
|
$writer->updateWorkspaceSetting(
|
||||||
|
actor: $manager,
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'managed_tenant_limit_override_value',
|
||||||
|
value: 2,
|
||||||
|
);
|
||||||
|
$writer->updateWorkspaceSetting(
|
||||||
|
actor: $manager,
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'managed_tenant_limit_override_reason',
|
||||||
|
value: 'Pilot workspace',
|
||||||
|
);
|
||||||
|
$writer->updateWorkspaceSetting(
|
||||||
|
actor: $manager,
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'review_pack_generation_override_value',
|
||||||
|
value: false,
|
||||||
|
);
|
||||||
|
$writer->updateWorkspaceSetting(
|
||||||
|
actor: $manager,
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'review_pack_generation_override_reason',
|
||||||
|
value: 'Escalation only',
|
||||||
|
);
|
||||||
|
|
||||||
|
$platformUser = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::DIRECTORY_VIEW,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($platformUser, 'platform')
|
||||||
|
->get(ViewWorkspace::getUrl(panel: 'system', parameters: ['workspace' => $workspace]))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Workspace entitlements')
|
||||||
|
->assertSee('Starter')
|
||||||
|
->assertSee('Pilot workspace')
|
||||||
|
->assertSee('Escalation only')
|
||||||
|
->assertSee('workspace override')
|
||||||
|
->assertDontSee('Save');
|
||||||
|
});
|
||||||
@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
|
use App\Services\Settings\SettingsWriter;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: Workspace, 1: User}
|
||||||
|
*/
|
||||||
|
function entitledWorkspaceManager(): array
|
||||||
|
{
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'manager',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$workspace, $user];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('falls back to the default plan profile when a workspace has no entitlement settings', function (): void {
|
||||||
|
[$workspace] = entitledWorkspaceManager();
|
||||||
|
|
||||||
|
$resolver = app(WorkspaceEntitlementResolver::class);
|
||||||
|
|
||||||
|
$managedTenantLimit = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
|
||||||
|
$reviewPackGeneration = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
|
||||||
|
|
||||||
|
expect($managedTenantLimit)
|
||||||
|
->toMatchArray([
|
||||||
|
'plan_profile_id' => 'standard',
|
||||||
|
'key' => WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
||||||
|
'effective_value' => 25,
|
||||||
|
'source' => 'plan_profile_default',
|
||||||
|
'current_usage' => 0,
|
||||||
|
'remaining_capacity' => 25,
|
||||||
|
'is_blocked' => false,
|
||||||
|
])
|
||||||
|
->and($managedTenantLimit['rationale'])->toBe('Balanced defaults for most managed workspaces.')
|
||||||
|
->and($managedTenantLimit['last_changed_at'])->toBeNull()
|
||||||
|
->and($managedTenantLimit['last_changed_by'])->toBeNull();
|
||||||
|
|
||||||
|
expect($reviewPackGeneration)
|
||||||
|
->toMatchArray([
|
||||||
|
'plan_profile_id' => 'standard',
|
||||||
|
'key' => WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
||||||
|
'effective_value' => true,
|
||||||
|
'source' => 'plan_profile_default',
|
||||||
|
'is_blocked' => false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the selected plan profile defaults when no explicit override is set', function (): void {
|
||||||
|
[$workspace, $user] = entitledWorkspaceManager();
|
||||||
|
|
||||||
|
app(SettingsWriter::class)->updateWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
|
||||||
|
key: WorkspaceEntitlementResolver::SETTING_PLAN_PROFILE,
|
||||||
|
value: 'starter',
|
||||||
|
);
|
||||||
|
|
||||||
|
$resolver = app(WorkspaceEntitlementResolver::class);
|
||||||
|
|
||||||
|
$managedTenantLimit = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
|
||||||
|
$reviewPackGeneration = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
|
||||||
|
|
||||||
|
expect($managedTenantLimit)
|
||||||
|
->toMatchArray([
|
||||||
|
'plan_profile_id' => 'starter',
|
||||||
|
'effective_value' => 1,
|
||||||
|
'source' => 'plan_profile_default',
|
||||||
|
'current_usage' => 0,
|
||||||
|
'remaining_capacity' => 1,
|
||||||
|
'is_blocked' => false,
|
||||||
|
])
|
||||||
|
->and($managedTenantLimit['last_changed_by'])->toBe($user->name)
|
||||||
|
->and($managedTenantLimit['last_changed_at'])->not->toBeNull();
|
||||||
|
|
||||||
|
expect($reviewPackGeneration)
|
||||||
|
->toMatchArray([
|
||||||
|
'plan_profile_id' => 'starter',
|
||||||
|
'effective_value' => false,
|
||||||
|
'source' => 'plan_profile_default',
|
||||||
|
'is_blocked' => true,
|
||||||
|
])
|
||||||
|
->and($reviewPackGeneration['block_reason'])->toContain('Starter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies workspace override values, rationale, and usage-aware blocking', function (): void {
|
||||||
|
[$workspace, $user] = entitledWorkspaceManager();
|
||||||
|
|
||||||
|
$writer = app(SettingsWriter::class);
|
||||||
|
|
||||||
|
$writer->updateWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
|
||||||
|
key: WorkspaceEntitlementResolver::SETTING_PLAN_PROFILE,
|
||||||
|
value: 'starter',
|
||||||
|
);
|
||||||
|
|
||||||
|
$writer->updateWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
|
||||||
|
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
|
||||||
|
value: 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
$writer->updateWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
|
||||||
|
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
|
||||||
|
value: 'Temporary support-approved exception',
|
||||||
|
);
|
||||||
|
|
||||||
|
Tenant::factory()->count(2)->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'status' => Tenant::STATUS_ACTIVE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$decision = app(WorkspaceEntitlementResolver::class)->resolve(
|
||||||
|
$workspace,
|
||||||
|
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($decision)
|
||||||
|
->toMatchArray([
|
||||||
|
'plan_profile_id' => 'starter',
|
||||||
|
'effective_value' => 2,
|
||||||
|
'source' => 'workspace_override',
|
||||||
|
'rationale' => 'Temporary support-approved exception',
|
||||||
|
'current_usage' => 2,
|
||||||
|
'remaining_capacity' => 0,
|
||||||
|
'is_blocked' => true,
|
||||||
|
'last_changed_by' => $user->name,
|
||||||
|
])
|
||||||
|
->and($decision['last_changed_at'])->not->toBeNull()
|
||||||
|
->and($decision['block_reason'])->toContain('workspace override')
|
||||||
|
->and($decision['block_reason'])->toContain('Temporary support-approved exception');
|
||||||
|
});
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
||||||
|
|
||||||
|
it('exposes a bounded profile catalog with exactly one default profile', function (): void {
|
||||||
|
$catalog = app(WorkspacePlanProfileCatalog::class);
|
||||||
|
$profiles = $catalog->all();
|
||||||
|
|
||||||
|
expect($profiles)
|
||||||
|
->toHaveCount(3)
|
||||||
|
->and(collect($profiles)->where('is_default', true))->toHaveCount(1)
|
||||||
|
->and(WorkspacePlanProfileCatalog::defaultProfileId())->toBe('standard')
|
||||||
|
->and($catalog->default()['label'])->toBe('Standard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves known profiles and falls back to the default for unknown identifiers', function (): void {
|
||||||
|
$catalog = app(WorkspacePlanProfileCatalog::class);
|
||||||
|
|
||||||
|
expect($catalog->resolve('starter'))
|
||||||
|
->toMatchArray([
|
||||||
|
'id' => 'starter',
|
||||||
|
'managed_tenant_limit_default' => 1,
|
||||||
|
'review_pack_generation_default' => false,
|
||||||
|
])
|
||||||
|
->and($catalog->resolve('missing-profile')['id'])->toBe('standard')
|
||||||
|
->and($catalog->optionLabels())
|
||||||
|
->toMatchArray([
|
||||||
|
'starter' => 'Starter',
|
||||||
|
'standard' => 'Standard',
|
||||||
|
'scale' => 'Scale',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Ai\AiDataClassification;
|
||||||
|
use App\Support\ProductKnowledge\ContextualHelpResolver;
|
||||||
|
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('exposes only the approved product knowledge source input for ai answer drafts', function (): void {
|
||||||
|
$source = app(ContextualHelpResolver::class)->aiProductKnowledgeAnswerDraftSource();
|
||||||
|
|
||||||
|
expect($source)->toMatchArray([
|
||||||
|
'use_case_key' => 'product_knowledge.answer_draft',
|
||||||
|
'source_family' => 'product_knowledge',
|
||||||
|
'data_classifications' => [
|
||||||
|
AiDataClassification::ProductKnowledge->value,
|
||||||
|
AiDataClassification::OperationalMetadata->value,
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->and($source['topics'])->not->toBeEmpty()
|
||||||
|
->and($source['operational_metadata'])->toHaveKeys(['version', 'topic_count'])
|
||||||
|
->and($source)->not->toHaveKeys(['tenant', 'tenant_id', 'workspace', 'workspace_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes only the approved redacted support summary input for ai diagnostic drafts', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$source = app(SupportDiagnosticBundleBuilder::class)->aiSupportDiagnosticsSummaryDraftSource($tenant);
|
||||||
|
|
||||||
|
expect($source)->toMatchArray([
|
||||||
|
'use_case_key' => 'support_diagnostics.summary_draft',
|
||||||
|
'source_family' => 'support_diagnostics',
|
||||||
|
'data_classifications' => [
|
||||||
|
AiDataClassification::RedactedSupportSummary->value,
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->and($source['summary'])->toHaveKeys([
|
||||||
|
'headline',
|
||||||
|
'dominant_issue',
|
||||||
|
'freshness_state',
|
||||||
|
'redaction_note',
|
||||||
|
'generated_from',
|
||||||
|
])
|
||||||
|
->and(data_get($source, 'redaction.mode'))->toBe('default_redacted')
|
||||||
|
->and($source)->not->toHaveKeys(['sections', 'context', 'tenant', 'workspace', 'operation_run']);
|
||||||
|
});
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Ai\AiDataClassification;
|
||||||
|
use App\Support\Ai\AiDecisionAuditMetadataFactory;
|
||||||
|
use App\Support\Ai\AiDecisionReasonCode;
|
||||||
|
use App\Support\Ai\AiExecutionDecision;
|
||||||
|
use App\Support\Ai\AiExecutionRequest;
|
||||||
|
use App\Support\Ai\AiProviderClass;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('builds bounded decision metadata without raw prompt, source, provider, or output payloads', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$tenant = Tenant::factory()->create(['workspace_id' => (int) $workspace->getKey()]);
|
||||||
|
|
||||||
|
$request = new AiExecutionRequest(
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: null,
|
||||||
|
useCaseKey: 'support_diagnostics.summary_draft',
|
||||||
|
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
||||||
|
dataClassifications: [AiDataClassification::RedactedSupportSummary->value],
|
||||||
|
sourceFamily: 'support_diagnostics',
|
||||||
|
callerSurface: 'support_diagnostics',
|
||||||
|
contextFingerprint: 'support_diagnostics:summary:v1',
|
||||||
|
);
|
||||||
|
|
||||||
|
$decision = new AiExecutionDecision(
|
||||||
|
outcome: 'blocked',
|
||||||
|
reasonCode: AiDecisionReasonCode::DataClassificationBlocked,
|
||||||
|
workspaceAiPolicyMode: 'private_only',
|
||||||
|
matchedOperationalControlScope: null,
|
||||||
|
useCaseKey: 'support_diagnostics.summary_draft',
|
||||||
|
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
||||||
|
dataClassifications: [AiDataClassification::RedactedSupportSummary->value],
|
||||||
|
sourceFamily: 'support_diagnostics',
|
||||||
|
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
|
||||||
|
auditMetadata: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
$metadata = app(AiDecisionAuditMetadataFactory::class)->make($request, $decision);
|
||||||
|
|
||||||
|
expect($metadata)->toMatchArray([
|
||||||
|
'use_case_key' => 'support_diagnostics.summary_draft',
|
||||||
|
'decision_outcome' => 'blocked',
|
||||||
|
'decision_reason' => AiDecisionReasonCode::DataClassificationBlocked->value,
|
||||||
|
'workspace_ai_policy_mode' => 'private_only',
|
||||||
|
'requested_provider_class' => 'local_private',
|
||||||
|
'data_classifications' => ['redacted_support_summary'],
|
||||||
|
'source_family' => 'support_diagnostics',
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'context_fingerprint' => 'support_diagnostics:summary:v1',
|
||||||
|
])
|
||||||
|
->and($metadata)->not->toHaveKeys([
|
||||||
|
'prompt_text',
|
||||||
|
'source_payload',
|
||||||
|
'provider_payload',
|
||||||
|
'output_text',
|
||||||
|
]);
|
||||||
|
});
|
||||||
48
apps/platform/tests/Unit/Support/Ai/AiUseCaseCatalogTest.php
Normal file
48
apps/platform/tests/Unit/Support/Ai/AiUseCaseCatalogTest.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Ai\AiDataClassification;
|
||||||
|
use App\Support\Ai\AiPolicyMode;
|
||||||
|
use App\Support\Ai\AiUseCaseCatalog;
|
||||||
|
|
||||||
|
it('locks the first slice to the two approved private-only use cases', function (): void {
|
||||||
|
$definitions = app(AiUseCaseCatalog::class)->all();
|
||||||
|
|
||||||
|
expect($definitions)->toHaveCount(2)
|
||||||
|
->and($definitions[0])->toMatchArray([
|
||||||
|
'key' => 'product_knowledge.answer_draft',
|
||||||
|
'label' => 'Product knowledge answer draft',
|
||||||
|
'future_consumer' => 'ContextualHelpResolver',
|
||||||
|
'source_family' => 'product_knowledge',
|
||||||
|
'tenant_context_permitted' => false,
|
||||||
|
])
|
||||||
|
->and($definitions[0]['allowed_provider_classes'])->toBe(['local_private'])
|
||||||
|
->and($definitions[0]['allowed_data_classifications'])->toBe([
|
||||||
|
'product_knowledge',
|
||||||
|
'operational_metadata',
|
||||||
|
])
|
||||||
|
->and($definitions[1])->toMatchArray([
|
||||||
|
'key' => 'support_diagnostics.summary_draft',
|
||||||
|
'label' => 'Support diagnostics summary draft',
|
||||||
|
'future_consumer' => 'SupportDiagnosticBundleBuilder',
|
||||||
|
'source_family' => 'support_diagnostics',
|
||||||
|
'tenant_context_permitted' => true,
|
||||||
|
])
|
||||||
|
->and($definitions[1]['allowed_provider_classes'])->toBe(['local_private'])
|
||||||
|
->and($definitions[1]['allowed_data_classifications'])->toBe([
|
||||||
|
'redacted_support_summary',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives provider and blocked-data summaries from the catalog for the workspace policy surface', function (): void {
|
||||||
|
$catalog = app(AiUseCaseCatalog::class);
|
||||||
|
|
||||||
|
expect($catalog->allowedProviderClassLabelsForMode(AiPolicyMode::Disabled))->toBe([])
|
||||||
|
->and($catalog->allowedProviderClassLabelsForMode(AiPolicyMode::PrivateOnly))->toBe(['Local private'])
|
||||||
|
->and($catalog->blockedDataClassificationLabels())->toBe([
|
||||||
|
AiDataClassification::PersonalData->label(),
|
||||||
|
AiDataClassification::CustomerConfidential->label(),
|
||||||
|
AiDataClassification::RawProviderPayload->label(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\OperationalControlActivation;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Models\WorkspaceSetting;
|
||||||
|
use App\Support\Ai\AiDataClassification;
|
||||||
|
use App\Support\Ai\AiDecisionReasonCode;
|
||||||
|
use App\Support\Ai\AiExecutionRequest;
|
||||||
|
use App\Support\Ai\AiProviderClass;
|
||||||
|
use App\Support\Ai\GovernedAiExecutionBoundary;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: Workspace, 1: User}
|
||||||
|
*/
|
||||||
|
function aiPolicyWorkspace(string $policyMode = 'private_only'): array
|
||||||
|
{
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'manager',
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkspaceSetting::query()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'domain' => 'ai',
|
||||||
|
'key' => 'policy_mode',
|
||||||
|
'value' => $policyMode,
|
||||||
|
'updated_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$workspace, $user];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('allows approved local-private support-diagnostics requests and writes bounded audit metadata', function (): void {
|
||||||
|
[$workspace, $user] = aiPolicyWorkspace();
|
||||||
|
$tenant = Tenant::factory()->create(['workspace_id' => (int) $workspace->getKey()]);
|
||||||
|
|
||||||
|
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $user,
|
||||||
|
useCaseKey: 'support_diagnostics.summary_draft',
|
||||||
|
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
||||||
|
dataClassifications: [AiDataClassification::RedactedSupportSummary->value],
|
||||||
|
sourceFamily: 'support_diagnostics',
|
||||||
|
callerSurface: 'support_diagnostics',
|
||||||
|
contextFingerprint: 'support_diagnostics:summary:v1',
|
||||||
|
)));
|
||||||
|
|
||||||
|
expect($decision->isAllowed())->toBeTrue()
|
||||||
|
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::Allowed)
|
||||||
|
->and($decision->workspaceAiPolicyMode)->toBe('private_only')
|
||||||
|
->and($decision->matchedOperationalControlScope)->toBeNull();
|
||||||
|
|
||||||
|
$audit = AuditLog::query()->latest('id')->first();
|
||||||
|
|
||||||
|
expect($audit)->not->toBeNull()
|
||||||
|
->and($audit?->action)->toBe(AuditActionId::AiExecutionDecisionEvaluated->value)
|
||||||
|
->and($audit?->workspace_id)->toBe((int) $workspace->getKey())
|
||||||
|
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
|
||||||
|
->and(data_get($audit?->metadata, 'decision_outcome'))->toBe('allowed')
|
||||||
|
->and(data_get($audit?->metadata, 'decision_reason'))->toBe(AiDecisionReasonCode::Allowed->value)
|
||||||
|
->and(data_get($audit?->metadata, 'use_case_key'))->toBe('support_diagnostics.summary_draft')
|
||||||
|
->and(data_get($audit?->metadata, 'requested_provider_class'))->toBe('local_private')
|
||||||
|
->and(data_get($audit?->metadata, 'data_classifications'))->toBe(['redacted_support_summary'])
|
||||||
|
->and(data_get($audit?->metadata, 'context_fingerprint'))->toBe('support_diagnostics:summary:v1')
|
||||||
|
->and(data_get($audit?->metadata, 'prompt_text'))->toBeNull()
|
||||||
|
->and(data_get($audit?->metadata, 'output_text'))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks external-public provider classes before any provider resolution', function (): void {
|
||||||
|
[$workspace, $user] = aiPolicyWorkspace();
|
||||||
|
|
||||||
|
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: null,
|
||||||
|
actor: $user,
|
||||||
|
useCaseKey: 'product_knowledge.answer_draft',
|
||||||
|
requestedProviderClass: AiProviderClass::ExternalPublic->value,
|
||||||
|
dataClassifications: [
|
||||||
|
AiDataClassification::ProductKnowledge->value,
|
||||||
|
AiDataClassification::OperationalMetadata->value,
|
||||||
|
],
|
||||||
|
sourceFamily: 'product_knowledge',
|
||||||
|
callerSurface: 'product_knowledge',
|
||||||
|
contextFingerprint: 'product_knowledge:answer:v1',
|
||||||
|
)));
|
||||||
|
|
||||||
|
expect($decision->isBlocked())->toBeTrue()
|
||||||
|
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::ProviderClassBlocked)
|
||||||
|
->and($decision->matchedOperationalControlScope)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks disallowed data classifications before any provider resolution', function (): void {
|
||||||
|
[$workspace, $user] = aiPolicyWorkspace();
|
||||||
|
$tenant = Tenant::factory()->create(['workspace_id' => (int) $workspace->getKey()]);
|
||||||
|
|
||||||
|
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $user,
|
||||||
|
useCaseKey: 'support_diagnostics.summary_draft',
|
||||||
|
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
||||||
|
dataClassifications: [AiDataClassification::RawProviderPayload->value],
|
||||||
|
sourceFamily: 'support_diagnostics',
|
||||||
|
callerSurface: 'support_diagnostics',
|
||||||
|
contextFingerprint: 'support_diagnostics:raw:v1',
|
||||||
|
)));
|
||||||
|
|
||||||
|
expect($decision->isBlocked())->toBeTrue()
|
||||||
|
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::DataClassificationBlocked);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks unregistered use cases', function (): void {
|
||||||
|
[$workspace, $user] = aiPolicyWorkspace();
|
||||||
|
|
||||||
|
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: null,
|
||||||
|
actor: $user,
|
||||||
|
useCaseKey: 'customer_email.reply',
|
||||||
|
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
||||||
|
dataClassifications: [AiDataClassification::ProductKnowledge->value],
|
||||||
|
sourceFamily: 'product_knowledge',
|
||||||
|
callerSurface: 'product_knowledge',
|
||||||
|
contextFingerprint: 'customer_email:reply:v1',
|
||||||
|
)));
|
||||||
|
|
||||||
|
expect($decision->isBlocked())->toBeTrue()
|
||||||
|
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::UnregisteredUseCase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the ai execution operational control override an otherwise valid request', function (): void {
|
||||||
|
[$workspace, $user] = aiPolicyWorkspace();
|
||||||
|
|
||||||
|
OperationalControlActivation::factory()->forGlobalScope()->create([
|
||||||
|
'control_key' => 'ai.execution',
|
||||||
|
'reason_text' => 'Paused for AI rollout review.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: null,
|
||||||
|
actor: $user,
|
||||||
|
useCaseKey: 'product_knowledge.answer_draft',
|
||||||
|
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
||||||
|
dataClassifications: [
|
||||||
|
AiDataClassification::ProductKnowledge->value,
|
||||||
|
AiDataClassification::OperationalMetadata->value,
|
||||||
|
],
|
||||||
|
sourceFamily: 'product_knowledge',
|
||||||
|
callerSurface: 'product_knowledge',
|
||||||
|
contextFingerprint: 'product_knowledge:answer:v1',
|
||||||
|
)));
|
||||||
|
|
||||||
|
expect($decision->isBlocked())->toBeTrue()
|
||||||
|
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::OperationalControlPaused)
|
||||||
|
->and($decision->matchedOperationalControlScope)->toBe('global');
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user