Compare commits
2 Commits
dev
...
244-produc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80bd9e3087 | ||
|
|
9f5d3293c5 |
@ -1,34 +1,30 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 2.10.0 -> 2.11.0
|
- Version change: 2.9.0 -> 2.10.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Expanded decision-first and operator-surface rules so operational,
|
- Expanded Operations / Run Observability Standard so OperationRun
|
||||||
governance, evidence, onboarding, review, and support-facing
|
start UX is shared-contract-owned instead of surface-owned
|
||||||
detail/status surfaces separate decision content, operator
|
- Expanded Governance review expectations for OperationRun-starting
|
||||||
diagnostics, and support/raw evidence
|
features, explicit queued-notification policy, and bounded
|
||||||
- Expanded review and enforcement expectations so specs, plans,
|
exceptions
|
||||||
tasks, and checklists must make audience modes, raw/support
|
|
||||||
gating, one dominant next action, and duplicate-truth prevention
|
|
||||||
explicit
|
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- Audience-Aware Decision Surfaces & Disclosure Ladder
|
- OperationRun Start UX Contract (OPS-UX-START-001): centralizes
|
||||||
(DECIDE-AUD-001): requires customer-readable default paths,
|
queued toast/link/event/message semantics, run/artifact deep links,
|
||||||
operator diagnostics as progressive disclosure, support/raw
|
queued DB-notification policy, and tenant/workspace-safe operation
|
||||||
evidence gating, one dominant next action, and no duplicate truth
|
URL resolution behind one shared OperationRun UX layer
|
||||||
across equal-priority cards
|
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- .specify/templates/spec-template.md: add audience-aware disclosure
|
- .specify/templates/spec-template.md: add OperationRun UX Impact
|
||||||
section + constitution prompts ✅
|
section + start-contract prompts ✅
|
||||||
- .specify/templates/plan-template.md: add audience/disclosure
|
- .specify/templates/plan-template.md: add OperationRun UX Impact
|
||||||
planning prompts + constitution checks ✅
|
planning section + constitution checks ✅
|
||||||
- .specify/templates/tasks-template.md: add decision/disclosure
|
- .specify/templates/tasks-template.md: add central start-UX reuse,
|
||||||
implementation + test tasks ✅
|
queued-notification policy, and exception tasks ✅
|
||||||
- .specify/templates/checklist-template.md: add disclosure, raw-gating,
|
- .specify/templates/checklist-template.md: add OperationRun start
|
||||||
one-primary-action, and duplicate-truth review checks ✅
|
UX review checks ✅
|
||||||
- docs/product/standards/README.md: refresh constitution index for
|
- docs/product/standards/README.md: refresh constitution index for
|
||||||
the new audience-aware disclosure contract ✅
|
the new ops-UX contract ✅
|
||||||
- Commands checked:
|
- Commands checked:
|
||||||
- N/A `.specify/templates/commands/*.md` directory is not present
|
- N/A `.specify/templates/commands/*.md` directory is not present
|
||||||
- Follow-up TODOs: None
|
- Follow-up TODOs: None
|
||||||
@ -593,114 +589,11 @@ ##### Review gate
|
|||||||
5. Is this a Primary Decision Surface, Secondary Context Surface, or
|
5. Is this a Primary Decision Surface, Secondary Context Surface, or
|
||||||
Tertiary Evidence / Diagnostics Surface?
|
Tertiary Evidence / Diagnostics Surface?
|
||||||
6. If it is primary, why can it not live inside an existing decision
|
6. If it is primary, why can it not live inside an existing decision
|
||||||
context?
|
context?
|
||||||
7. Does the navigation reflect a workflow or only storage structure?
|
7. Does the navigation reflect a workflow or only storage structure?
|
||||||
8. Does this reduce search, review, or click work?
|
8. Does this reduce search, review, or click work?
|
||||||
9. Does this make the product calmer and clearer instead of louder?
|
9. Does this make the product calmer and clearer instead of louder?
|
||||||
|
|
||||||
#### Audience-Aware Decision Surfaces & Disclosure Ladder (DECIDE-AUD-001)
|
|
||||||
|
|
||||||
Goal: every operational, governance, evidence, onboarding, review, and
|
|
||||||
support-facing detail or status surface MUST keep customer-readable
|
|
||||||
decision content, operator diagnostics, and support/raw evidence
|
|
||||||
intentionally separated while preserving full depth through progressive
|
|
||||||
disclosure.
|
|
||||||
|
|
||||||
##### Audience ladder is explicit
|
|
||||||
|
|
||||||
- In-scope detail and status surfaces MUST define their content using
|
|
||||||
this three-tier hierarchy when applicable:
|
|
||||||
- decision content
|
|
||||||
- operator diagnostics
|
|
||||||
- support / raw evidence
|
|
||||||
- Surfaces that are reachable by more than one audience class MUST
|
|
||||||
define their default-visible content for at least these layers when
|
|
||||||
applicable:
|
|
||||||
- customer / read-only default
|
|
||||||
- operator / MSP diagnostics
|
|
||||||
- platform / support raw evidence
|
|
||||||
- The surface contract MUST state which capabilities unlock each deeper
|
|
||||||
layer.
|
|
||||||
- Support/raw evidence MUST NOT become the default first-read
|
|
||||||
experience on customer-readable or ordinary operator-facing
|
|
||||||
surfaces.
|
|
||||||
|
|
||||||
##### Customer-readable default path
|
|
||||||
|
|
||||||
- The default reading path for customer/read-only users MUST optimize
|
|
||||||
for status, reason, impact, one dominant next action, and a short
|
|
||||||
result or artifact summary.
|
|
||||||
- Internal lifecycle wording, debug semantics, implementation field
|
|
||||||
names, raw payload fragments, and support-oriented context MUST NOT
|
|
||||||
appear in the default customer-readable path unless they are the only
|
|
||||||
way to understand the first decision.
|
|
||||||
- Default-visible customer/read-only content is responsible for status,
|
|
||||||
reason, impact, the dominant next action, and a concise supporting
|
|
||||||
summary only.
|
|
||||||
|
|
||||||
##### Diagnostics are secondary by default
|
|
||||||
|
|
||||||
- Diagnostics such as lifecycle, timings, verification detail, drift
|
|
||||||
detail, permission detail, provider summaries, or related-operation
|
|
||||||
context MUST be lower-priority than the decision surface and MUST be
|
|
||||||
collapsed, tabbed, grouped, or otherwise progressively disclosed when
|
|
||||||
the first decision does not require them.
|
|
||||||
- Authorized operators MAY expand diagnostics, but diagnostics MUST NOT
|
|
||||||
visually compete with the primary decision block.
|
|
||||||
- Where no support/raw tier is exposed, diagnostics still remain below
|
|
||||||
the decision tier and MUST NOT restate the same decision summary at
|
|
||||||
equal weight.
|
|
||||||
|
|
||||||
##### Raw/support evidence is gated
|
|
||||||
|
|
||||||
- Raw/support evidence such as JSON, raw context payloads,
|
|
||||||
fingerprints, internal reason ownership, platform reason families,
|
|
||||||
monitoring detail, viewer context, or copy/show-raw actions MUST NOT
|
|
||||||
appear in the default decision path.
|
|
||||||
- These details MUST live behind explicit reveal affordances and MUST
|
|
||||||
be capability-gated wherever the audience model distinguishes support
|
|
||||||
or platform users from ordinary operators.
|
|
||||||
- Capability-gated support/raw disclosure MUST fail closed when the
|
|
||||||
actor lacks the required scope or capability.
|
|
||||||
|
|
||||||
##### One dominant next action
|
|
||||||
|
|
||||||
- A decision surface MUST expose exactly one dominant next action in
|
|
||||||
the default-visible region.
|
|
||||||
- Optional secondary actions MAY exist, but they MUST NOT compete with
|
|
||||||
the primary remediation or decision action in prominence.
|
|
||||||
- Contextual navigation such as opening a related run, tenant, report,
|
|
||||||
or technical detail remains secondary.
|
|
||||||
|
|
||||||
##### No duplicate truth across equal-priority cards
|
|
||||||
|
|
||||||
- The same blocker, reason, or next action MUST NOT be repeated across
|
|
||||||
multiple equal-priority cards, sections, or summary blocks on the
|
|
||||||
same default-visible surface.
|
|
||||||
- Supporting evidence MAY restate the underlying proof, but the
|
|
||||||
dominant decision message appears once and diagnostics elaborate
|
|
||||||
beneath it.
|
|
||||||
|
|
||||||
##### Required tests
|
|
||||||
|
|
||||||
- New or materially changed customer/operator-facing detail surfaces
|
|
||||||
MUST include focused tests proving:
|
|
||||||
- default-visible content shows status, reason, impact, and next
|
|
||||||
action,
|
|
||||||
- exactly one dominant next action is primary,
|
|
||||||
- diagnostics are secondary or collapsed,
|
|
||||||
- raw/support evidence is not default-visible,
|
|
||||||
- support/raw sections are capability-gated where applicable,
|
|
||||||
- and duplicate visible decision summaries are absent.
|
|
||||||
|
|
||||||
##### Stored evidence wins over fallback diagnostics
|
|
||||||
|
|
||||||
- When a stored verification or report artifact exists, fallback
|
|
||||||
technical diagnostics SHOULD demote behind supporting evidence or
|
|
||||||
technical details instead of remaining peer-level default content.
|
|
||||||
- Fallback diagnostics MAY become temporarily prominent only when the
|
|
||||||
higher-level artifact does not yet exist or is unavailable.
|
|
||||||
|
|
||||||
#### Surface Taxonomy (UI-SURF-001)
|
#### Surface Taxonomy (UI-SURF-001)
|
||||||
|
|
||||||
Every new admin surface MUST be assigned exactly one broad action-surface
|
Every new admin surface MUST be assigned exactly one broad action-surface
|
||||||
@ -1424,22 +1317,11 @@ #### Operator Surface Principles (OPSURF-001)
|
|||||||
- Diagnostic detail MAY exist, but it MUST be secondary and explicitly revealed.
|
- Diagnostic detail MAY exist, but it MUST be secondary and explicitly revealed.
|
||||||
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces rather than the primary content region.
|
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces rather than the primary content region.
|
||||||
- Operators MUST NOT need to parse raw payloads to understand current state or next action.
|
- Operators MUST NOT need to parse raw payloads to understand current state or next action.
|
||||||
- Detail/status surfaces MUST satisfy DECIDE-AUD-001: decision content
|
|
||||||
first, operator diagnostics second, support/raw evidence third.
|
|
||||||
|
|
||||||
Distinct truth dimensions
|
Distinct truth dimensions
|
||||||
- When the domain has execution outcome, data completeness, governance result, lifecycle or readiness state, operability truth, health truth, trust/confidence, or next action semantics, the surface MUST keep them explicit instead of collapsing them into one ambiguous status.
|
- When the domain has execution outcome, data completeness, governance result, lifecycle or readiness state, operability truth, health truth, trust/confidence, or next action semantics, the surface MUST keep them explicit instead of collapsing them into one ambiguous status.
|
||||||
- If multiple truth dimensions are summarized, the default-visible UI MUST label each dimension clearly.
|
- If multiple truth dimensions are summarized, the default-visible UI MUST label each dimension clearly.
|
||||||
|
|
||||||
Dominant next action and duplicate-truth control
|
|
||||||
- Default-visible decision content MUST include status, reason,
|
|
||||||
impact, and one dominant next action where those concepts exist.
|
|
||||||
- Secondary navigation or debug helpers MUST remain lower-priority
|
|
||||||
than the dominant decision action.
|
|
||||||
- The same blocker, reason, impact, or next action MUST NOT be
|
|
||||||
repeated across multiple default-visible cards, sections, tabs, or
|
|
||||||
summaries.
|
|
||||||
|
|
||||||
Explicit mutation scope
|
Explicit mutation scope
|
||||||
- Every state-changing action MUST communicate before execution whether it affects TenantPilot only, the Microsoft tenant, or simulation only.
|
- Every state-changing action MUST communicate before execution whether it affects TenantPilot only, the Microsoft tenant, or simulation only.
|
||||||
- Mutation scope MUST be understandable from nearby action copy, helper text, preview, or confirmation.
|
- Mutation scope MUST be understandable from nearby action copy, helper text, preview, or confirmation.
|
||||||
@ -1460,13 +1342,6 @@ #### Operator Surface Principles (OPSURF-001)
|
|||||||
Page contract requirement
|
Page contract requirement
|
||||||
- Every new or materially refactored operator-facing page MUST define the primary persona, surface type, primary operator question, default-visible information, diagnostics-only information, status dimensions used, mutation scope, primary actions, and dangerous actions.
|
- Every new or materially refactored operator-facing page MUST define the primary persona, surface type, primary operator question, default-visible information, diagnostics-only information, status dimensions used, mutation scope, primary actions, and dangerous actions.
|
||||||
- The page contract MUST live in the governing spec and stay in sync with implementation.
|
- The page contract MUST live in the governing spec and stay in sync with implementation.
|
||||||
- Where multiple audience classes share the page, the contract MUST
|
|
||||||
explicitly define the customer/read-only default path, operator
|
|
||||||
diagnostics path, support/raw-evidence path, and the capabilities
|
|
||||||
that unlock each layer.
|
|
||||||
- The page contract MUST also make the dominant next action,
|
|
||||||
duplicate-truth prevention, and raw/support gating explicit for
|
|
||||||
changed detail/status surfaces.
|
|
||||||
|
|
||||||
#### Spec Scope Fields (SCOPE-002)
|
#### Spec Scope Fields (SCOPE-002)
|
||||||
|
|
||||||
@ -1491,11 +1366,8 @@ #### Enforcement Model (UI-REVIEW-001)
|
|||||||
native, custom, or a shared detail family, what shared core vs host
|
native, custom, or a shared detail family, what shared core vs host
|
||||||
variation exists if relevant, which layer owns the relevant shell,
|
variation exists if relevant, which layer owns the relevant shell,
|
||||||
page, and detail truth, which requested/active/draft/inspect/
|
page, and detail truth, which requested/active/draft/inspect/
|
||||||
restorable roles exist, which audience ladder and disclosure
|
restorable roles exist, whether any fake-native or host-drift risk is
|
||||||
boundaries exist, what the dominant next action is, how raw/support
|
present, and whether an exception type is used.
|
||||||
evidence is gated, how duplicate truth is prevented, whether any
|
|
||||||
fake-native or host-drift risk is present, and whether an exception
|
|
||||||
type is used.
|
|
||||||
- Missing any of those answers makes the spec incomplete.
|
- Missing any of those answers makes the spec incomplete.
|
||||||
|
|
||||||
PR review requirements
|
PR review requirements
|
||||||
@ -1510,12 +1382,7 @@ #### Enforcement Model (UI-REVIEW-001)
|
|||||||
promoted into primary navigation without justification, one case
|
promoted into primary navigation without justification, one case
|
||||||
fragmented across multiple equal-rank pages, new automation that adds
|
fragmented across multiple equal-rank pages, new automation that adds
|
||||||
attention surfaces without reducing operator work, noisy default
|
attention surfaces without reducing operator work, noisy default
|
||||||
surfaces with no action/watch/reference hierarchy, duplicate visible
|
surfaces with no action/watch/reference hierarchy, `Filament Costume`,
|
||||||
blocker/reason/next-action summaries, customer/operator default paths
|
|
||||||
that expose raw JSON, fingerprints, reason ownership, platform reason
|
|
||||||
families, or monitoring detail, helper actions such as `Open
|
|
||||||
operation`, `Technical details`, or `Show JSON` competing with the
|
|
||||||
dominant decision action, `Filament Costume`,
|
|
||||||
`Blade Request UI`, `Hand-Rolled Simple Overview`, `Hidden Exception`,
|
`Blade Request UI`, `Hand-Rolled Simple Overview`, `Hidden Exception`,
|
||||||
`Host Drift`, `State Layer Collapse`, `Parallel Inspect Worlds`, or
|
`Host Drift`, `State Layer Collapse`, `Parallel Inspect Worlds`, or
|
||||||
undocumented exceptions without dedicated tests.
|
undocumented exceptions without dedicated tests.
|
||||||
@ -1527,15 +1394,11 @@ #### Enforcement Model (UI-REVIEW-001)
|
|||||||
presence of explicit Inspect on Queue / Review and History / Audit
|
presence of explicit Inspect on Queue / Review and History / Audit
|
||||||
surfaces, absence of empty `ActionGroup` or `BulkActionGroup`,
|
surfaces, absence of empty `ActionGroup` or `BulkActionGroup`,
|
||||||
correct placement of destructive actions, truthful scope signals,
|
correct placement of destructive actions, truthful scope signals,
|
||||||
stable canonical nouns across shells, presence of a single dominant
|
stable canonical nouns across shells, absence of fake-native primary
|
||||||
next action where surface metadata exposes one, absence of duplicate
|
controls where metadata says the surface is native, bounded shared
|
||||||
visible decision summaries, explicit raw/support gating or secondary
|
family contracts where metadata says a family is reused, explicit
|
||||||
placement where the surface serves multiple audience classes,
|
state ownership where specs or metadata expose it, and dedicated
|
||||||
absence of fake-native primary controls where metadata says the
|
tests for every approved exception.
|
||||||
surface is native, bounded shared family contracts where metadata
|
|
||||||
says a family is reused, explicit state ownership where specs or
|
|
||||||
metadata expose it, and dedicated tests for every approved
|
|
||||||
exception.
|
|
||||||
|
|
||||||
#### Immediate Retrofit Priorities
|
#### Immediate Retrofit Priorities
|
||||||
|
|
||||||
@ -1602,10 +1465,6 @@ #### Appendix A - One-page Condensed Constitution
|
|||||||
- Scope chips must be truthful.
|
- Scope chips must be truthful.
|
||||||
- Domain nouns are canonical and stable.
|
- Domain nouns are canonical and stable.
|
||||||
- Critical operational truth is default-visible.
|
- Critical operational truth is default-visible.
|
||||||
- Multi-audience detail/status surfaces keep customer-readable decision
|
|
||||||
content above operator diagnostics and support/raw evidence.
|
|
||||||
- One dominant next action stays visually primary.
|
|
||||||
- Duplicate visible decision truth is forbidden.
|
|
||||||
- Semantic truth dimensions are not collapsed into a generic status.
|
- Semantic truth dimensions are not collapsed into a generic status.
|
||||||
- Standard lists stay scanable.
|
- Standard lists stay scanable.
|
||||||
- Exceptions are catalogued, justified, and tested.
|
- Exceptions are catalogued, justified, and tested.
|
||||||
@ -1618,8 +1477,6 @@ #### Appendix B - Feature Review Checklist
|
|||||||
- The human-in-the-loop moment is explicit.
|
- The human-in-the-loop moment is explicit.
|
||||||
- Immediate-visible decision information is explicit.
|
- Immediate-visible decision information is explicit.
|
||||||
- On-demand evidence / diagnostics boundaries are explicit.
|
- On-demand evidence / diagnostics boundaries are explicit.
|
||||||
- Audience-aware default visibility and raw-evidence boundaries are
|
|
||||||
explicit where the page serves more than one audience class.
|
|
||||||
- Any new primary surface is justified against an existing decision
|
- Any new primary surface is justified against an existing decision
|
||||||
context.
|
context.
|
||||||
- Navigation reflects a workflow rather than storage structure.
|
- Navigation reflects a workflow rather than storage structure.
|
||||||
@ -1629,8 +1486,6 @@ #### Appendix B - Feature Review Checklist
|
|||||||
- Broad action-surface class is declared.
|
- Broad action-surface class is declared.
|
||||||
- Detailed surface type is declared.
|
- Detailed surface type is declared.
|
||||||
- The one most likely next operator action is explicit.
|
- The one most likely next operator action is explicit.
|
||||||
- One dominant next action stays primary.
|
|
||||||
- Duplicate visible decision truth is absent.
|
|
||||||
- The surface is classified correctly as native, custom, or shared
|
- The surface is classified correctly as native, custom, or shared
|
||||||
family.
|
family.
|
||||||
- Primary inspect/open model is defined.
|
- Primary inspect/open model is defined.
|
||||||
@ -1712,10 +1567,6 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
|||||||
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
|
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
|
||||||
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
||||||
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
|
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
|
||||||
- Local Blade/Tailwind cards are allowed only when they preserve dark
|
|
||||||
mode correctness, spacing consistency, badge semantics, action
|
|
||||||
hierarchy, progressive disclosure, accessibility, and overall
|
|
||||||
Filament visual language.
|
|
||||||
|
|
||||||
Native-by-default classification
|
Native-by-default classification
|
||||||
- `Native Surface` means the primary interaction contract is built from
|
- `Native Surface` means the primary interaction contract is built from
|
||||||
@ -1747,8 +1598,6 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
|||||||
more than one host, it becomes a `Shared Detail Micro-UI` and MUST
|
more than one host, it becomes a `Shared Detail Micro-UI` and MUST
|
||||||
define shared core vs host variation before another host reassembles
|
define shared core vs host variation before another host reassembles
|
||||||
it locally.
|
it locally.
|
||||||
- Local one-off markup MUST NOT recreate decision/diagnostics/raw
|
|
||||||
layering when an existing shared detail family is sufficient.
|
|
||||||
|
|
||||||
Upgrade-safe preference
|
Upgrade-safe preference
|
||||||
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
||||||
@ -1762,9 +1611,7 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
|||||||
- and the deviation is justified briefly in code and in the governing spec or PR.
|
- and the deviation is justified briefly in code and in the governing spec or PR.
|
||||||
- Approved exceptions MUST stay layout-neutral, use the minimum local
|
- Approved exceptions MUST stay layout-neutral, use the minimum local
|
||||||
classes necessary, MUST NOT invent a new page-local status language,
|
classes necessary, MUST NOT invent a new page-local status language,
|
||||||
MUST preserve dark mode correctness, spacing consistency,
|
and MUST say what remains standardized.
|
||||||
badge semantics, action hierarchy, progressive disclosure,
|
|
||||||
accessibility, and MUST say what remains standardized.
|
|
||||||
- `Hidden Exception` is forbidden. Historical accident or local
|
- `Hidden Exception` is forbidden. Historical accident or local
|
||||||
implementation convenience is not a valid substitute for UI-EX-001.
|
implementation convenience is not a valid substitute for UI-EX-001.
|
||||||
|
|
||||||
@ -1773,8 +1620,6 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
|||||||
- which native Filament element or shared primitive was used,
|
- which native Filament element or shared primitive was used,
|
||||||
- why an existing component was insufficient if an exception was taken,
|
- why an existing component was insufficient if an exception was taken,
|
||||||
- whether the surface is native, custom, or a shared detail family,
|
- whether the surface is native, custom, or a shared detail family,
|
||||||
- whether any local Blade/Tailwind card still preserves Filament
|
|
||||||
visual language and disclosure semantics,
|
|
||||||
- and whether any ad-hoc status, emphasis styling, or fake-native
|
- and whether any ad-hoc status, emphasis styling, or fake-native
|
||||||
contract was introduced.
|
contract was introduced.
|
||||||
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
||||||
@ -1813,11 +1658,6 @@ ### Scope, Compliance, and Review Expectations
|
|||||||
different policy, route terminal notifications through the lifecycle mechanism, include an `OperationRun UX Impact`
|
different policy, route terminal notifications through the lifecycle mechanism, include an `OperationRun UX Impact`
|
||||||
section in the active spec or plan, and document any temporary exception with an architecture note, test rationale,
|
section in the active spec or plan, and document any temporary exception with an architecture note, test rationale,
|
||||||
and migration decision.
|
and migration decision.
|
||||||
- Specs and PRs that change detail or status surfaces MUST explicitly
|
|
||||||
document how they satisfy customer-readable decision-first content,
|
|
||||||
diagnostics-secondary disclosure, support/raw-evidence gating, one
|
|
||||||
dominant next action, duplicate-truth prevention, and shared-pattern
|
|
||||||
reuse.
|
|
||||||
- Specs and PRs that change operator-facing surfaces MUST classify each
|
- Specs and PRs that change operator-facing surfaces MUST classify each
|
||||||
affected surface under DECIDE-001 and justify any new Primary
|
affected surface under DECIDE-001 and justify any new Primary
|
||||||
Decision Surface or workflow-first navigation change.
|
Decision Surface or workflow-first navigation change.
|
||||||
@ -1835,4 +1675,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 2.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-27
|
**Version**: 2.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-24
|
||||||
|
|||||||
@ -51,14 +51,6 @@ ## Signals, Exceptions, And Test Depth
|
|||||||
- [ ] CHK014 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
- [ ] CHK014 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
||||||
- [ ] CHK015 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
- [ ] CHK015 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
||||||
|
|
||||||
## Audience-Aware Disclosure And Decision Hierarchy
|
|
||||||
|
|
||||||
- [ ] CHK023 Default-visible content is decision-first and clearly separated from operator diagnostics and support/raw evidence.
|
|
||||||
- [ ] CHK024 Customer/read-only default paths do not expose raw JSON, copied context payloads, fingerprints, internal reason ownership, platform reason families, monitoring detail, or other debug semantics by default.
|
|
||||||
- [ ] CHK025 Exactly one dominant next action is primary; navigation or debug helpers such as `Open operation`, `Technical details`, or `Show JSON` do not compete at equal weight.
|
|
||||||
- [ ] CHK026 Duplicate visible status, blocker, reason, impact, or next-action summaries are removed or explicitly justified as non-duplicative evidence.
|
|
||||||
- [ ] CHK027 Support/raw sections are collapsed, lower-priority, or capability-gated where applicable, and any local Blade/Tailwind surface still preserves Filament visual language, dark mode correctness, progressive disclosure, and accessibility.
|
|
||||||
|
|
||||||
## Review Outcome
|
## Review Outcome
|
||||||
|
|
||||||
- [ ] CHK016 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
- [ ] CHK016 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
||||||
|
|||||||
@ -36,10 +36,6 @@ ## UI / Surface Guardrail Plan
|
|||||||
- **Native vs custom classification summary**: [native / custom / mixed / N/A]
|
- **Native vs custom classification summary**: [native / custom / mixed / N/A]
|
||||||
- **Shared-family relevance**: [none / list affected shared families]
|
- **Shared-family relevance**: [none / list affected shared families]
|
||||||
- **State layers in scope**: [shell / page / detail / URL-query / none]
|
- **State layers in scope**: [shell / page / detail / URL-query / none]
|
||||||
- **Audience modes in scope**: [customer/read-only / operator-MSP / support-platform / N/A]
|
|
||||||
- **Decision/diagnostic/raw hierarchy plan**: [decision-first / diagnostics-second / support-raw-third / N/A]
|
|
||||||
- **Raw/support gating plan**: [collapsed / capability-gated / role-gated / N/A]
|
|
||||||
- **One-primary-action / duplicate-truth control**: [how one dominant next action is preserved and repeated blockers are removed]
|
|
||||||
- **Handling modes by drift class or surface**: [hard-stop-candidate / review-mandatory / exception-required / report-only / N/A]
|
- **Handling modes by drift class or surface**: [hard-stop-candidate / review-mandatory / exception-required / report-only / N/A]
|
||||||
- **Repository-signal treatment**: [report-only / review-mandatory / exception-required / future hard-stop candidate / N/A]
|
- **Repository-signal treatment**: [report-only / review-mandatory / exception-required / future hard-stop candidate / N/A]
|
||||||
- **Special surface test profiles**: [standard-native-filament / shared-detail-family / monitoring-state-page / global-context-shell / exception-coded-surface / N/A]
|
- **Special surface test profiles**: [standard-native-filament / shared-detail-family / monitoring-state-page / global-context-shell / exception-coded-surface / N/A]
|
||||||
@ -115,10 +111,6 @@ ## Constitution Check
|
|||||||
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
||||||
- Filament-native UI (UI-FIL-001): if local Blade/Tailwind cards are
|
|
||||||
still necessary, they preserve dark mode correctness, spacing
|
|
||||||
consistency, badge semantics, action hierarchy, progressive
|
|
||||||
disclosure, accessibility, and Filament visual language
|
|
||||||
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
|
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
|
||||||
- Decision-first operating model (DECIDE-001): each changed
|
- Decision-first operating model (DECIDE-001): each changed
|
||||||
operator-facing surface is classified as Primary Decision,
|
operator-facing surface is classified as Primary Decision,
|
||||||
@ -128,13 +120,6 @@ ## Constitution Check
|
|||||||
disclosed, one governance case stays decidable in one context where
|
disclosed, one governance case stays decidable in one context where
|
||||||
practical, navigation follows workflows not storage structures, and
|
practical, navigation follows workflows not storage structures, and
|
||||||
automation / alerts reduce attention load instead of adding noise
|
automation / alerts reduce attention load instead of adding noise
|
||||||
- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): detail or
|
|
||||||
status surfaces separate customer-readable decision content,
|
|
||||||
operator diagnostics, and support/raw evidence; customer-readable
|
|
||||||
default paths hide raw JSON, copied context, fingerprints, internal
|
|
||||||
reason ownership, platform reason families, and debug semantics;
|
|
||||||
one dominant next action is explicit; duplicate visible truth is
|
|
||||||
removed
|
|
||||||
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
|
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
|
||||||
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): standard CRUD and Registry rows expose at most one inline safe shortcut; destructive actions are grouped or in the detail header; queue exceptions are catalogued, justified, and tested
|
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): standard CRUD and Registry rows expose at most one inline safe shortcut; destructive actions are grouped or in the detail header; queue exceptions are catalogued, justified, and tested
|
||||||
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): scope signals are truthful, canonical nouns stay stable across shells, critical operational truth is default-visible, and standard lists remain scanable
|
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): scope signals are truthful, canonical nouns stay stable across shells, critical operational truth is default-visible, and standard lists remain scanable
|
||||||
|
|||||||
@ -89,17 +89,6 @@ ## Decision-First Surface Role *(mandatory when operator-facing surfaces are cha
|
|||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
| e.g. Review inbox | Primary Decision Surface | Review and release queued governance work | Case summary, severity, recommendation, required action | Full evidence, raw payloads, audit trail, provider diagnostics | Primary because it is the queue where operators decide and clear work | Follows pending-decisions workflow, not storage objects | Removes search across runs, findings, and audit pages |
|
| e.g. Review inbox | Primary Decision Surface | Review and release queued governance work | Case summary, severity, recommendation, required action | Full evidence, raw payloads, audit trail, provider diagnostics | Primary because it is the queue where operators decide and clear work | Follows pending-decisions workflow, not storage objects | Removes search across runs, findings, and audit pages |
|
||||||
|
|
||||||
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
If this feature adds or materially changes a detail or status surface,
|
|
||||||
fill out one row per affected surface. Reuse the same surface names
|
|
||||||
used above and make the disclosure hierarchy explicit instead of
|
|
||||||
assuming it.
|
|
||||||
|
|
||||||
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| e.g. Review inbox | customer-read-only, operator-MSP, support-platform | Current status, why it matters, impact, recommendation, next action | Review history, lifecycle, related evidence, related runs | Raw payloads, fingerprints, reason ownership, platform reason family | `Review evidence` | Raw/support detail hidden or capability-gated outside support mode | The top summary states the blocker once; later sections add evidence rather than restating it |
|
|
||||||
|
|
||||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
||||||
@ -265,13 +254,6 @@ ## Requirements *(mandatory)*
|
|||||||
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
|
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
|
||||||
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
|
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
|
||||||
|
|
||||||
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** If this feature changes a detail or status surface, the spec MUST describe:
|
|
||||||
- how the surface separates customer-readable decision content, operator diagnostics, and support/raw evidence,
|
|
||||||
- which audience modes are in scope (`customer/read-only`, `operator/MSP`, `support/platform`),
|
|
||||||
- which content is hidden, collapsed, or capability-gated by default,
|
|
||||||
- how one dominant next action is preserved,
|
|
||||||
- and how duplicate visible truth is prevented.
|
|
||||||
|
|
||||||
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
|
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
|
||||||
- classify each touched seam as provider-owned or platform-core,
|
- classify each touched seam as provider-owned or platform-core,
|
||||||
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
|
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
|
||||||
@ -328,7 +310,6 @@ ## Requirements *(mandatory)*
|
|||||||
- which native Filament components or shared UI primitives are used,
|
- which native Filament components or shared UI primitives are used,
|
||||||
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
||||||
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
||||||
- how any required local Blade/Tailwind cards still preserve dark mode correctness, spacing consistency, badge semantics, action hierarchy, progressive disclosure, accessibility, and Filament visual language,
|
|
||||||
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||||
@ -386,7 +367,6 @@ ## Requirements *(mandatory)*
|
|||||||
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||||
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
||||||
- which diagnostics are secondary and how they are explicitly revealed,
|
- which diagnostics are secondary and how they are explicitly revealed,
|
||||||
- how the dominant next action stays primary and how duplicate visible truth is avoided,
|
|
||||||
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
||||||
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
||||||
|
|||||||
@ -78,21 +78,9 @@ # Tasks: [FEATURE NAME]
|
|||||||
- filling the spec’s Operator Surface Contract for every affected page,
|
- filling the spec’s Operator Surface Contract for every affected page,
|
||||||
- keeping default-visible content limited to first-decision needs and
|
- keeping default-visible content limited to first-decision needs and
|
||||||
moving proof, payloads, and diagnostics into progressive disclosure,
|
moving proof, payloads, and diagnostics into progressive disclosure,
|
||||||
- implementing the three-tier disclosure hierarchy where applicable:
|
|
||||||
customer-readable decision content first, operator diagnostics
|
|
||||||
second, support/raw evidence third,
|
|
||||||
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
||||||
- ensuring customer/read-only default paths do not expose raw JSON,
|
|
||||||
copied context payloads, fingerprints, internal reason ownership,
|
|
||||||
platform reason families, or debug semantics,
|
|
||||||
- keeping each governance case decidable in one focused context where
|
- keeping each governance case decidable in one focused context where
|
||||||
practical instead of forcing cross-page reconstruction,
|
practical instead of forcing cross-page reconstruction,
|
||||||
- keeping exactly one dominant next action primary and demoting
|
|
||||||
navigation/debug helpers such as `Open operation`, `Technical
|
|
||||||
details`, or `Show JSON`,
|
|
||||||
- removing duplicate visible status, blocker, reason, impact, or
|
|
||||||
next-action summaries so later sections add evidence instead of
|
|
||||||
restating the same decision truth,
|
|
||||||
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
||||||
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
||||||
@ -140,12 +128,6 @@ # Tasks: [FEATURE NAME]
|
|||||||
- documenting any catalogued UI exception in the spec/PR and adding dedicated test coverage,
|
- documenting any catalogued UI exception in the spec/PR and adding dedicated test coverage,
|
||||||
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
||||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||||
- For any new or modified customer/operator-facing detail surface,
|
|
||||||
tests MUST prove default-visible status/reason/impact/next-action
|
|
||||||
content, exactly one dominant next action, diagnostics-secondary
|
|
||||||
ordering, hidden raw/support detail by default, capability-gated
|
|
||||||
support/raw sections where applicable, and the absence of duplicate
|
|
||||||
visible decision summaries.
|
|
||||||
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
||||||
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
||||||
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
|
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
<?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,7 +6,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -31,7 +30,6 @@
|
|||||||
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;
|
||||||
@ -42,10 +40,6 @@
|
|||||||
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;
|
||||||
@ -147,6 +141,10 @@ 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;
|
||||||
}
|
}
|
||||||
@ -169,14 +167,6 @@ 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;
|
||||||
@ -238,6 +228,8 @@ 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()
|
||||||
@ -259,85 +251,39 @@ 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 = $this->resolveViewerActor();
|
$user = auth()->user();
|
||||||
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
$tenant = $this->supportDiagnosticsTenant();
|
||||||
|
|
||||||
|
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 = $this->resolveViewerActor();
|
$user = auth()->user();
|
||||||
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
$tenant = $this->supportDiagnosticsTenant();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$this->recordSupportDiagnosticsOpened(
|
$this->recordSupportDiagnosticsOpened(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -361,59 +307,6 @@ 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,7 +9,6 @@
|
|||||||
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;
|
||||||
@ -177,10 +176,6 @@ 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([])
|
||||||
|
|||||||
@ -8,8 +8,6 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceSetting;
|
use App\Models\WorkspaceSetting;
|
||||||
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;
|
||||||
@ -23,7 +21,6 @@
|
|||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\KeyValue;
|
use Filament\Forms\Components\KeyValue;
|
||||||
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;
|
||||||
@ -61,23 +58,10 @@ 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).
|
||||||
*
|
*
|
||||||
@ -127,14 +111,6 @@ 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}.
|
||||||
*
|
*
|
||||||
@ -204,50 +180,6 @@ 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('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([
|
||||||
@ -523,56 +455,6 @@ 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);
|
||||||
@ -608,7 +490,6 @@ 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();
|
||||||
}
|
}
|
||||||
@ -682,25 +563,15 @@ 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->canResetField($field))
|
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($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->canResetField($field)) {
|
if (! $this->hasWorkspaceOverride($field)) {
|
||||||
if ($this->isEntitlementOverrideValueField($field)) {
|
|
||||||
return 'No workspace override to reset.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'No workspace override to reset.';
|
return 'No workspace override to reset.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -708,149 +579,6 @@ 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 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;
|
||||||
@ -993,27 +721,6 @@ 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,7 +11,6 @@
|
|||||||
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;
|
||||||
@ -21,14 +20,8 @@
|
|||||||
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;
|
||||||
@ -77,72 +70,10 @@ 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')
|
||||||
@ -173,16 +104,34 @@ private function openSupportDiagnosticsAction(): Action
|
|||||||
*/
|
*/
|
||||||
public function tenantSupportDiagnosticBundle(): array
|
public function tenantSupportDiagnosticBundle(): array
|
||||||
{
|
{
|
||||||
$user = $this->resolveDashboardActor();
|
$user = auth()->user();
|
||||||
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
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 = $this->resolveDashboardActor();
|
$user = auth()->user();
|
||||||
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$this->recordSupportDiagnosticsOpened(
|
$this->recordSupportDiagnosticsOpened(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -223,57 +172,4 @@ 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.';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,6 @@
|
|||||||
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;
|
||||||
@ -663,16 +662,7 @@ 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()
|
||||||
@ -710,7 +700,9 @@ 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->completionActionTooltip())
|
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)
|
||||||
|
? null
|
||||||
|
: 'Owner required to complete onboarding.')
|
||||||
->action(fn () => $this->completeOnboarding()),
|
->action(fn () => $this->completeOnboarding()),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
@ -4506,10 +4498,6 @@ 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(
|
||||||
@ -4542,111 +4530,6 @@ 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();
|
||||||
@ -4980,16 +4863,6 @@ 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,8 +2,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -12,7 +10,6 @@
|
|||||||
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;
|
||||||
@ -48,8 +45,6 @@
|
|||||||
|
|
||||||
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';
|
||||||
@ -107,9 +102,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 appears in the list header once review packs exist.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state carries the single Generate CTA while the registry is empty.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
||||||
->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.');
|
||||||
@ -355,62 +350,41 @@ 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([
|
||||||
static::generatePackAction(name: 'generate_first', label: 'Generate first pack'),
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('generate_first')
|
||||||
|
->label('Generate first pack')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->action(function (array $data): void {
|
||||||
|
static::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(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
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')
|
|
||||||
->disabled(fn (): bool => static::reviewPackGenerationBlocked())
|
|
||||||
->action(function (array $data): void {
|
|
||||||
static::executeGeneration($data);
|
|
||||||
})
|
|
||||||
->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')
|
|
||||||
->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)),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
$tenant = 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()
|
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
|
||||||
->with(['tenant', 'operationRun', 'evidenceSnapshot', 'tenantReview'])
|
|
||||||
->where('tenant_id', (int) $tenant->getKey());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
@ -484,14 +458,6 @@ 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;
|
||||||
|
|
||||||
@ -527,55 +493,4 @@ 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,7 +3,12 @@
|
|||||||
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
|
||||||
{
|
{
|
||||||
@ -12,13 +17,29 @@ class ListReviewPacks extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
ReviewPackResource::generatePackAction()
|
UiEnforcement::forAction(
|
||||||
->visible(fn (): bool => $this->tableHasRecords()),
|
Actions\Action::make('generate_pack')
|
||||||
|
->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,51 +19,6 @@ class ViewReviewPack extends ViewRecord
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$regenerateAction = UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('regenerate')
|
|
||||||
->label('Regenerate')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('primary')
|
|
||||||
->disabled(fn (): bool => ReviewPackResource::reviewPackGenerationBlocked($this->record->tenant))
|
|
||||||
->requiresConfirmation()
|
|
||||||
->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 {
|
|
||||||
/** @var ReviewPack $record */
|
|
||||||
$record = $this->record;
|
|
||||||
|
|
||||||
$options = array_merge($record->options ?? [], [
|
|
||||||
'include_pii' => (bool) ($data['include_pii'] ?? ($record->options['include_pii'] ?? true)),
|
|
||||||
'include_operations' => (bool) ($data['include_operations'] ?? ($record->options['include_operations'] ?? true)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ReviewPackResource::executeGeneration($options);
|
|
||||||
})
|
|
||||||
->form(function (): array {
|
|
||||||
/** @var ReviewPack $record */
|
|
||||||
$record = $this->record;
|
|
||||||
$currentOptions = $record->options ?? [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
Section::make('Pack options')
|
|
||||||
->schema([
|
|
||||||
Toggle::make('include_pii')
|
|
||||||
->label('Include PII')
|
|
||||||
->helperText('Include personally identifiable information in the export.')
|
|
||||||
->default((bool) ($currentOptions['include_pii'] ?? true)),
|
|
||||||
Toggle::make('include_operations')
|
|
||||||
->label('Include operations')
|
|
||||||
->helperText('Include recent operation history in the export.')
|
|
||||||
->default((bool) ($currentOptions['include_operations'] ?? true)),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
|
||||||
->preserveDisabled()
|
|
||||||
->apply();
|
|
||||||
|
|
||||||
$regenerateAction->tooltip(fn (): ?string => ReviewPackResource::reviewPackGenerationActionTooltip($this->record->tenant));
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Actions\Action::make('download')
|
Actions\Action::make('download')
|
||||||
->label('Download')
|
->label('Download')
|
||||||
@ -73,7 +28,46 @@ protected function getHeaderActions(): array
|
|||||||
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
|
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
|
|
||||||
$regenerateAction,
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('regenerate')
|
||||||
|
->label('Regenerate')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->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 {
|
||||||
|
/** @var ReviewPack $record */
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
$options = array_merge($record->options ?? [], [
|
||||||
|
'include_pii' => (bool) ($data['include_pii'] ?? ($record->options['include_pii'] ?? true)),
|
||||||
|
'include_operations' => (bool) ($data['include_operations'] ?? ($record->options['include_operations'] ?? true)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
ReviewPackResource::executeGeneration($options);
|
||||||
|
})
|
||||||
|
->form(function (): array {
|
||||||
|
/** @var ReviewPack $record */
|
||||||
|
$record = $this->record;
|
||||||
|
$currentOptions = $record->options ?? [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
Section::make('Pack options')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('include_pii')
|
||||||
|
->label('Include PII')
|
||||||
|
->helperText('Include personally identifiable information in the export.')
|
||||||
|
->default((bool) ($currentOptions['include_pii'] ?? true)),
|
||||||
|
Toggle::make('include_operations')
|
||||||
|
->label('Include operations')
|
||||||
|
->helperText('Include recent operation history in the export.')
|
||||||
|
->default((bool) ($currentOptions['include_operations'] ?? true)),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||||
|
->apply(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
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;
|
||||||
@ -16,7 +15,6 @@
|
|||||||
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;
|
||||||
@ -243,25 +241,6 @@ 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()
|
||||||
@ -308,7 +287,20 @@ 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([
|
||||||
$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))
|
||||||
|
->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')
|
||||||
@ -431,50 +423,6 @@ 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']);
|
||||||
@ -509,10 +457,6 @@ 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
|
||||||
{
|
{
|
||||||
$action = UiEnforcement::forAction(
|
return 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,17 +241,11 @@ 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,8 +6,6 @@
|
|||||||
|
|
||||||
use App\Filament\System\Widgets\ControlTowerHealthIndicator;
|
use App\Filament\System\Widgets\ControlTowerHealthIndicator;
|
||||||
use App\Filament\System\Widgets\ControlTowerKpis;
|
use App\Filament\System\Widgets\ControlTowerKpis;
|
||||||
use App\Filament\System\Widgets\CustomerHealthKpis;
|
|
||||||
use App\Filament\System\Widgets\CustomerHealthTopWorkspaces;
|
|
||||||
use App\Filament\System\Widgets\ProductTelemetryKpis;
|
use App\Filament\System\Widgets\ProductTelemetryKpis;
|
||||||
use App\Filament\System\Widgets\ControlTowerRecentFailures;
|
use App\Filament\System\Widgets\ControlTowerRecentFailures;
|
||||||
use App\Filament\System\Widgets\ControlTowerTopOffenders;
|
use App\Filament\System\Widgets\ControlTowerTopOffenders;
|
||||||
@ -64,12 +62,6 @@ public function getWidgets(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
ControlTowerHealthIndicator::class,
|
ControlTowerHealthIndicator::class,
|
||||||
new WidgetConfiguration(CustomerHealthKpis::class, [
|
|
||||||
'window' => $this->window,
|
|
||||||
]),
|
|
||||||
new WidgetConfiguration(CustomerHealthTopWorkspaces::class, [
|
|
||||||
'window' => $this->window,
|
|
||||||
]),
|
|
||||||
new WidgetConfiguration(ControlTowerKpis::class, [
|
new WidgetConfiguration(ControlTowerKpis::class, [
|
||||||
'window' => $this->window,
|
'window' => $this->window,
|
||||||
]),
|
]),
|
||||||
|
|||||||
@ -1,144 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\System\Pages\Directory\Concerns;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\CustomerHealth\CustomerHealthDimensionCatalog;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
|
|
||||||
trait BuildsCustomerHealthDecisionData
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array{
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>
|
|
||||||
* } $summary
|
|
||||||
* @return array{
|
|
||||||
* overall: array{label: string, color: string, icon: string|null},
|
|
||||||
* reason: string,
|
|
||||||
* impact: string,
|
|
||||||
* recommended_action: string,
|
|
||||||
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
|
|
||||||
* window_label: string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
protected function buildCustomerHealthDecision(array $summary, SystemConsoleWindow $window, string $surface): array
|
|
||||||
{
|
|
||||||
$overallBadge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $summary['overall_level']);
|
|
||||||
|
|
||||||
$dominantDimensions = collect($summary['dominant_dimension_keys'])
|
|
||||||
->map(function (string $dimensionKey) use ($summary): ?array {
|
|
||||||
$dimension = $summary['dimensions'][$dimensionKey] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($dimension)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$badge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $dimension['level']);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'label' => $dimension['label'],
|
|
||||||
'color' => $badge->color,
|
|
||||||
'icon' => $badge->icon,
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->filter()
|
|
||||||
->take(2)
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$dominantLabels = array_map(static fn (array $dimension): string => $dimension['label'], $dominantDimensions);
|
|
||||||
$primaryDimension = $summary['dominant_dimension_keys'][0] ?? null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'overall' => [
|
|
||||||
'label' => $overallBadge->label,
|
|
||||||
'color' => $overallBadge->color,
|
|
||||||
'icon' => $overallBadge->icon,
|
|
||||||
],
|
|
||||||
'reason' => $this->customerHealthReason($dominantLabels),
|
|
||||||
'impact' => $this->customerHealthImpact($summary['overall_level'], $primaryDimension),
|
|
||||||
'recommended_action' => $this->customerHealthRecommendedAction($summary['overall_level'], $primaryDimension, $surface),
|
|
||||||
'dominant_dimensions' => $dominantDimensions,
|
|
||||||
'window_label' => SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $dominantLabels
|
|
||||||
*/
|
|
||||||
protected function customerHealthReason(array $dominantLabels): string
|
|
||||||
{
|
|
||||||
if ($dominantLabels === []) {
|
|
||||||
return 'No active health drivers are pressuring this workspace right now.';
|
|
||||||
}
|
|
||||||
|
|
||||||
$labelPrefix = count($dominantLabels) === 1 ? 'Top driver' : 'Top drivers';
|
|
||||||
|
|
||||||
return $labelPrefix.': '.implode(', ', $dominantLabels);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function customerHealthImpact(string $overallLevel, ?string $primaryDimension): string
|
|
||||||
{
|
|
||||||
if ($overallLevel === 'ok') {
|
|
||||||
return 'Tracked onboarding, provider, operational, governance, review-pack, and engagement signals are currently stable.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($overallLevel === 'unknown') {
|
|
||||||
return 'Some required health truth is missing or stale, so this workspace cannot be treated as healthy yet.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($primaryDimension) {
|
|
||||||
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $overallLevel === 'critical'
|
|
||||||
? 'Onboarding readiness is blocked, so this workspace cannot be treated as operationally ready.'
|
|
||||||
: 'Onboarding readiness still needs follow-up before this workspace can be treated as fully stable.',
|
|
||||||
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $overallLevel === 'critical'
|
|
||||||
? 'Default provider consent or verification is blocking reliable tenant management.'
|
|
||||||
: 'Provider connectivity has degraded and may impact reliable tenant management.',
|
|
||||||
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => $overallLevel === 'critical'
|
|
||||||
? 'Failed or stuck operations are actively putting delivery at risk.'
|
|
||||||
: 'Recent operational noise is starting to erode delivery confidence.',
|
|
||||||
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $overallLevel === 'critical'
|
|
||||||
? 'High-severity or expired governance pressure needs immediate review.'
|
|
||||||
: 'Governance pressure is active and should be reviewed before it escalates.',
|
|
||||||
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => $overallLevel === 'critical'
|
|
||||||
? 'Recent review-pack work is unusable or expired, so review readiness is blocked.'
|
|
||||||
: 'Review-pack readiness is incomplete, so recent review evidence may not be usable yet.',
|
|
||||||
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $overallLevel === 'critical'
|
|
||||||
? 'Recent product activity is missing, which suggests active usage may be deteriorating.'
|
|
||||||
: 'Recent product activity is thinning out and may indicate adoption drift.',
|
|
||||||
default => $overallLevel === 'critical'
|
|
||||||
? 'This workspace needs immediate operator follow-up.'
|
|
||||||
: 'This workspace needs follow-up soon to prevent further drift.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function customerHealthRecommendedAction(string $overallLevel, ?string $primaryDimension, string $surface): string
|
|
||||||
{
|
|
||||||
if ($overallLevel === 'ok') {
|
|
||||||
return 'Continue normal monitoring from the system dashboard.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($primaryDimension) {
|
|
||||||
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $surface === 'tenant'
|
|
||||||
? 'Confirm the tenant onboarding state with the responsible tenant admin and clear the blocking step.'
|
|
||||||
: 'Open the affected tenant below and confirm which onboarding step is blocked.',
|
|
||||||
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $surface === 'tenant'
|
|
||||||
? 'Review connectivity signals below and confirm the default provider consent and verification state.'
|
|
||||||
: 'Open the affected tenant below and review the default provider connection state.',
|
|
||||||
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => 'Review recent operations below and triage failed or stuck runs first.',
|
|
||||||
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $surface === 'tenant'
|
|
||||||
? 'Review governance findings or exception pressure for this tenant before proceeding.'
|
|
||||||
: 'Open the affected tenant below and review governance findings or exceptions.',
|
|
||||||
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => 'Check recent review-pack activity and confirm that a usable pack exists for the current window.',
|
|
||||||
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $surface === 'tenant'
|
|
||||||
? 'Confirm whether missing recent product activity is expected for this tenant.'
|
|
||||||
: 'Confirm whether missing recent product activity is expected across this workspace.',
|
|
||||||
default => 'Review the diagnostics below to confirm which source truth needs operator follow-up.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,25 +4,20 @@
|
|||||||
|
|
||||||
namespace App\Filament\System\Pages\Directory;
|
namespace App\Filament\System\Pages\Directory;
|
||||||
|
|
||||||
use App\Filament\System\Pages\Directory\Concerns\BuildsCustomerHealthDecisionData;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantPermission;
|
use App\Models\TenantPermission;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
use App\Support\System\SystemDirectoryLinks;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class ViewTenant extends Page
|
class ViewTenant extends Page
|
||||||
{
|
{
|
||||||
use BuildsCustomerHealthDecisionData;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static ?string $slug = 'directory/tenants/{tenant}';
|
protected static ?string $slug = 'directory/tenants/{tenant}';
|
||||||
@ -107,26 +102,4 @@ public function runsUrl(): string
|
|||||||
{
|
{
|
||||||
return SystemOperationRunLinks::index();
|
return SystemOperationRunLinks::index();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* overall: array{label: string, color: string, icon: string|null},
|
|
||||||
* reason: string,
|
|
||||||
* impact: string,
|
|
||||||
* recommended_action: string,
|
|
||||||
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
|
|
||||||
* window_label: string
|
|
||||||
* }|null
|
|
||||||
*/
|
|
||||||
public function customerHealthDecision(): ?array
|
|
||||||
{
|
|
||||||
$window = SystemConsoleWindow::fromNullable(request()->query('window'));
|
|
||||||
$summary = app(WorkspaceHealthSummaryQuery::class)->summaryForWorkspace((int) $this->tenant->workspace_id, $window);
|
|
||||||
|
|
||||||
if (! is_array($summary)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->buildCustomerHealthDecision($summary, $window, 'tenant');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,25 +4,19 @@
|
|||||||
|
|
||||||
namespace App\Filament\System\Pages\Directory;
|
namespace App\Filament\System\Pages\Directory;
|
||||||
|
|
||||||
use App\Filament\System\Pages\Directory\Concerns\BuildsCustomerHealthDecisionData;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\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}';
|
||||||
@ -85,34 +79,4 @@ 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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\System\Widgets;
|
|
||||||
|
|
||||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
|
||||||
|
|
||||||
class CustomerHealthKpis extends StatsOverviewWidget
|
|
||||||
{
|
|
||||||
protected static bool $isLazy = false;
|
|
||||||
|
|
||||||
protected ?string $heading = 'Customer health';
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
public ?string $window = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Stat>
|
|
||||||
*/
|
|
||||||
protected function getStats(): array
|
|
||||||
{
|
|
||||||
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
|
|
||||||
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
|
|
||||||
$counts = app(WorkspaceHealthSummaryQuery::class)->healthCounts($window);
|
|
||||||
|
|
||||||
return [
|
|
||||||
Stat::make('Healthy', $counts['ok'])
|
|
||||||
->description(sprintf('Operational stability, review-pack readiness, and engagement freshness honor %s.', $windowLabel))
|
|
||||||
->color($counts['ok'] > 0 ? 'success' : 'gray'),
|
|
||||||
Stat::make('Warning', $counts['warn'])
|
|
||||||
->description('Onboarding readiness, provider health, and governance pressure stay point-in-time.')
|
|
||||||
->color($counts['warn'] > 0 ? 'warning' : 'gray'),
|
|
||||||
Stat::make('Critical', $counts['critical'])
|
|
||||||
->description('Overall workspace health is derived from existing system truth only.')
|
|
||||||
->color($counts['critical'] > 0 ? 'danger' : 'gray'),
|
|
||||||
Stat::make('Unknown', $counts['unknown'])
|
|
||||||
->description('Missing or stale inputs stay explicit instead of silently reading healthy.')
|
|
||||||
->color('gray'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,137 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\System\Widgets;
|
|
||||||
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
|
||||||
use Filament\Widgets\Widget;
|
|
||||||
|
|
||||||
class CustomerHealthTopWorkspaces extends Widget
|
|
||||||
{
|
|
||||||
protected static bool $isLazy = false;
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
protected string $view = 'filament.system.widgets.customer-health-top-workspaces';
|
|
||||||
|
|
||||||
public ?string $window = null;
|
|
||||||
|
|
||||||
public static function canView(): bool
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
if (! $user instanceof PlatformUser) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! static::canOpenRuns($user)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
|
||||||
|
|
||||||
return app(WorkspaceHealthSummaryQuery::class)
|
|
||||||
->attentionNeeded($window, 10)
|
|
||||||
->contains(fn (array $summary): bool => static::canAccessNextLink($summary['next_link'], $user));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function getViewData(): array
|
|
||||||
{
|
|
||||||
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
|
|
||||||
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'windowLabel' => $windowLabel,
|
|
||||||
'rows' => app(WorkspaceHealthSummaryQuery::class)
|
|
||||||
->attentionNeeded($window, 10)
|
|
||||||
->filter(fn (array $summary): bool => $user instanceof PlatformUser && static::canAccessNextLink($summary['next_link'], $user))
|
|
||||||
->map(fn (array $summary): array => $this->presentSummary($summary)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{label: string, url: string} $nextLink
|
|
||||||
*/
|
|
||||||
private static function canAccessNextLink(array $nextLink, PlatformUser $user): bool
|
|
||||||
{
|
|
||||||
if ($user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return static::canOpenRuns($user)
|
|
||||||
&& $nextLink['url'] === SystemOperationRunLinks::index();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function canOpenRuns(PlatformUser $user): bool
|
|
||||||
{
|
|
||||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
|
||||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_name: string,
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>,
|
|
||||||
* non_ok_dimension_count: int,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* } $summary
|
|
||||||
* @return array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_label: string,
|
|
||||||
* overall: array{label: string, color: string, icon: ?string},
|
|
||||||
* dominant_copy: string,
|
|
||||||
* dominant_dimensions: list<array{label: string, color: string, icon: ?string}>,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private function presentSummary(array $summary): array
|
|
||||||
{
|
|
||||||
$dominantDimensions = collect($summary['dominant_dimension_keys'])
|
|
||||||
->take(2)
|
|
||||||
->map(function (string $dimensionKey) use ($summary): array {
|
|
||||||
$dimension = $summary['dimensions'][$dimensionKey];
|
|
||||||
$badge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $dimension['level']);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'label' => $dimension['label'],
|
|
||||||
'color' => $badge->color,
|
|
||||||
'icon' => $badge->icon,
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$overallBadge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $summary['overall_level']);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace_id' => $summary['workspace_id'],
|
|
||||||
'workspace_label' => $summary['workspace_name'],
|
|
||||||
'overall' => [
|
|
||||||
'label' => $overallBadge->label,
|
|
||||||
'color' => $overallBadge->color,
|
|
||||||
'icon' => $overallBadge->icon,
|
|
||||||
],
|
|
||||||
'dominant_copy' => implode(', ', array_column($dominantDimensions, 'label')),
|
|
||||||
'dominant_dimensions' => $dominantDimensions,
|
|
||||||
'next_link' => $summary['next_link'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -19,7 +18,6 @@
|
|||||||
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
|
||||||
@ -68,18 +66,6 @@ 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())
|
||||||
@ -104,20 +90,10 @@ 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)
|
||||||
@ -154,14 +130,6 @@ 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'])
|
||||||
@ -178,8 +146,6 @@ 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,
|
||||||
@ -228,8 +194,6 @@ 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,
|
||||||
@ -260,8 +224,6 @@ 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,
|
||||||
|
|||||||
@ -1,121 +0,0 @@
|
|||||||
<?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,10 +4,9 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -15,7 +14,6 @@
|
|||||||
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
|
||||||
{
|
{
|
||||||
@ -138,39 +136,4 @@ 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,7 +20,6 @@ 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,
|
||||||
@ -66,7 +65,6 @@ 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,
|
||||||
@ -108,7 +106,6 @@ 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,
|
||||||
|
|||||||
@ -1,327 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
<?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,7 +4,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -14,7 +13,6 @@
|
|||||||
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;
|
||||||
@ -30,7 +28,6 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -52,8 +49,6 @@ 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);
|
||||||
@ -143,8 +138,6 @@ 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);
|
||||||
@ -246,17 +239,6 @@ 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(
|
||||||
@ -332,17 +314,6 @@ 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 = [
|
||||||
|
|||||||
@ -100,7 +100,6 @@ 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 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';
|
||||||
@ -242,7 +241,6 @@ 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::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',
|
||||||
@ -329,7 +327,6 @@ 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::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,9 +72,6 @@ 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';
|
||||||
|
|
||||||
|
|||||||
@ -1,112 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\CustomerHealth;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
final class CustomerHealthDimensionCatalog
|
|
||||||
{
|
|
||||||
public const string ONBOARDING_READINESS = 'onboarding_readiness';
|
|
||||||
|
|
||||||
public const string PROVIDER_CONNECTION_HEALTH = 'provider_connection_health';
|
|
||||||
|
|
||||||
public const string OPERATIONAL_STABILITY = 'operational_stability';
|
|
||||||
|
|
||||||
public const string GOVERNANCE_PRESSURE = 'governance_pressure';
|
|
||||||
|
|
||||||
public const string REVIEW_PACK_READINESS = 'review_pack_readiness';
|
|
||||||
|
|
||||||
public const string ENGAGEMENT_FRESHNESS = 'engagement_freshness';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, array{label: string, windowed: bool}>
|
|
||||||
*/
|
|
||||||
private const DEFINITIONS = [
|
|
||||||
self::ONBOARDING_READINESS => [
|
|
||||||
'label' => 'Onboarding readiness',
|
|
||||||
'windowed' => false,
|
|
||||||
],
|
|
||||||
self::PROVIDER_CONNECTION_HEALTH => [
|
|
||||||
'label' => 'Provider connection health',
|
|
||||||
'windowed' => false,
|
|
||||||
],
|
|
||||||
self::OPERATIONAL_STABILITY => [
|
|
||||||
'label' => 'Operational stability',
|
|
||||||
'windowed' => true,
|
|
||||||
],
|
|
||||||
self::GOVERNANCE_PRESSURE => [
|
|
||||||
'label' => 'Governance pressure',
|
|
||||||
'windowed' => false,
|
|
||||||
],
|
|
||||||
self::REVIEW_PACK_READINESS => [
|
|
||||||
'label' => 'Review-pack readiness',
|
|
||||||
'windowed' => true,
|
|
||||||
],
|
|
||||||
self::ENGAGEMENT_FRESHNESS => [
|
|
||||||
'label' => 'Engagement freshness',
|
|
||||||
'windowed' => true,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function names(): array
|
|
||||||
{
|
|
||||||
return array_keys(self::DEFINITIONS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{label: string, windowed: bool}
|
|
||||||
*/
|
|
||||||
public function definition(string $dimension): array
|
|
||||||
{
|
|
||||||
$definition = self::DEFINITIONS[$dimension] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($definition)) {
|
|
||||||
throw new InvalidArgumentException("Unknown customer health dimension [{$dimension}].");
|
|
||||||
}
|
|
||||||
|
|
||||||
return $definition;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function label(string $dimension): string
|
|
||||||
{
|
|
||||||
return $this->definition($dimension)['label'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isWindowed(string $dimension): bool
|
|
||||||
{
|
|
||||||
return $this->definition($dimension)['windowed'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function visibleDimensions(): array
|
|
||||||
{
|
|
||||||
$dimensions = [];
|
|
||||||
|
|
||||||
foreach ($this->names() as $dimension) {
|
|
||||||
$dimensions[$dimension] = $this->label($dimension);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $dimensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $levels
|
|
||||||
*/
|
|
||||||
public function resolveOverallLevel(array $levels): string
|
|
||||||
{
|
|
||||||
foreach (['critical', 'warn', 'unknown'] as $level) {
|
|
||||||
if (in_array($level, $levels, true)) {
|
|
||||||
return $level;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,766 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\CustomerHealth;
|
|
||||||
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\FindingException;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ProductUsageEvent;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantOnboardingSession;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Onboarding\OnboardingLifecycleState;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
|
||||||
use App\Support\SystemConsole\StuckRunClassifier;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
|
|
||||||
final class WorkspaceHealthSummaryQuery
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly CustomerHealthDimensionCatalog $dimensionCatalog,
|
|
||||||
private readonly StuckRunClassifier $stuckRunClassifier,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_name: string,
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>,
|
|
||||||
* non_ok_dimension_count: int,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
public function summaries(SystemConsoleWindow|string|null $window = null, ?CarbonImmutable $now = null): Collection
|
|
||||||
{
|
|
||||||
$resolvedWindow = $this->resolveWindow($window);
|
|
||||||
$now ??= CarbonImmutable::now();
|
|
||||||
$startAt = $resolvedWindow->startAt($now);
|
|
||||||
|
|
||||||
$workspaces = Workspace::query()
|
|
||||||
->whereNull('archived_at')
|
|
||||||
->orderBy('name')
|
|
||||||
->orderBy('id')
|
|
||||||
->get(['id', 'name']);
|
|
||||||
|
|
||||||
if ($workspaces->isEmpty()) {
|
|
||||||
return collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceIds = $workspaces
|
|
||||||
->pluck('id')
|
|
||||||
->map(static fn (mixed $workspaceId): int => (int) $workspaceId)
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$activeTenants = Tenant::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->whereNull('deleted_at')
|
|
||||||
->where('status', '!=', Tenant::STATUS_ARCHIVED)
|
|
||||||
->orderBy('name')
|
|
||||||
->orderBy('id')
|
|
||||||
->get(['id', 'workspace_id', 'external_id', 'name', 'status']);
|
|
||||||
|
|
||||||
$tenantsByWorkspace = $activeTenants->groupBy(static fn (Tenant $tenant): int => (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
$latestOnboardingSessions = TenantOnboardingSession::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
->orderByDesc('updated_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->get(['id', 'workspace_id', 'tenant_id', 'lifecycle_state', 'updated_at', 'created_at'])
|
|
||||||
->groupBy(static fn (TenantOnboardingSession $session): int => (int) $session->workspace_id)
|
|
||||||
->map(static fn (Collection $sessions): ?TenantOnboardingSession => $sessions->first());
|
|
||||||
|
|
||||||
$providerConnectionsByWorkspace = ProviderConnection::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where('is_default', true)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
->orderByDesc('is_enabled')
|
|
||||||
->orderBy('id')
|
|
||||||
->get([
|
|
||||||
'id',
|
|
||||||
'workspace_id',
|
|
||||||
'tenant_id',
|
|
||||||
'is_enabled',
|
|
||||||
'consent_status',
|
|
||||||
'verification_status',
|
|
||||||
])
|
|
||||||
->groupBy(static fn (ProviderConnection $connection): int => (int) $connection->workspace_id);
|
|
||||||
|
|
||||||
$recentRunCounts = $this->groupedCounts(
|
|
||||||
OperationRun::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where('created_at', '>=', $startAt)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$recentFailedRunCounts = $this->groupedCounts(
|
|
||||||
OperationRun::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where('created_at', '>=', $startAt)
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Failed->value)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$recentStuckRunCounts = $this->groupedCounts(
|
|
||||||
$this->stuckRunClassifier->apply(
|
|
||||||
OperationRun::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where('created_at', '>=', $startAt)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
}),
|
|
||||||
$now,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$activeHighSeverityFindingCounts = $this->groupedCounts(
|
|
||||||
Finding::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->whereIn('severity', Finding::highSeverityValues())
|
|
||||||
->whereIn('status', Finding::openStatusesForQuery())
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$anyGovernanceFindingCounts = $this->groupedCounts(
|
|
||||||
Finding::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$overdueHighSeverityFindingCounts = $this->groupedCounts(
|
|
||||||
Finding::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->whereIn('severity', Finding::highSeverityValues())
|
|
||||||
->whereIn('status', Finding::openStatusesForQuery())
|
|
||||||
->whereNotNull('due_at')
|
|
||||||
->where('due_at', '<', $now)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$warningExceptionCounts = $this->groupedCounts(
|
|
||||||
FindingException::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$query
|
|
||||||
->whereIn('status', [
|
|
||||||
FindingException::STATUS_PENDING,
|
|
||||||
FindingException::STATUS_EXPIRING,
|
|
||||||
])
|
|
||||||
->orWhere('current_validity_state', FindingException::VALIDITY_EXPIRING);
|
|
||||||
})
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$criticalExceptionCounts = $this->groupedCounts(
|
|
||||||
FindingException::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$query
|
|
||||||
->whereIn('status', [
|
|
||||||
FindingException::STATUS_EXPIRED,
|
|
||||||
FindingException::STATUS_REVOKED,
|
|
||||||
])
|
|
||||||
->orWhereIn('current_validity_state', [
|
|
||||||
FindingException::VALIDITY_EXPIRED,
|
|
||||||
FindingException::VALIDITY_REVOKED,
|
|
||||||
FindingException::VALIDITY_REJECTED,
|
|
||||||
FindingException::VALIDITY_MISSING_SUPPORT,
|
|
||||||
]);
|
|
||||||
})
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$reviewPackRequestCounts = $this->groupedCounts(
|
|
||||||
ProductUsageEvent::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where('event_name', ProductUsageEventCatalog::REVIEW_PACK_REQUESTED)
|
|
||||||
->where('occurred_at', '>=', $startAt)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$recentReviewPacks = ReviewPack::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where('created_at', '>=', $startAt)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->get(['id', 'workspace_id', 'tenant_id', 'status', 'expires_at', 'created_at'])
|
|
||||||
->groupBy(static fn (ReviewPack $reviewPack): int => (int) $reviewPack->workspace_id)
|
|
||||||
->map(static fn (Collection $reviewPacks): ?ReviewPack => $reviewPacks->first());
|
|
||||||
|
|
||||||
$recentUsageEventCounts = $this->groupedCounts(
|
|
||||||
ProductUsageEvent::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where('occurred_at', '>=', $startAt)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$historicalUsageEventCounts = $this->groupedCounts(
|
|
||||||
ProductUsageEvent::query()
|
|
||||||
->whereIn('workspace_id', $workspaceIds)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$this->constrainToActiveTenantTruth($query);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return $workspaces
|
|
||||||
->map(function (Workspace $workspace) use (
|
|
||||||
$tenantsByWorkspace,
|
|
||||||
$latestOnboardingSessions,
|
|
||||||
$providerConnectionsByWorkspace,
|
|
||||||
$recentRunCounts,
|
|
||||||
$recentFailedRunCounts,
|
|
||||||
$recentStuckRunCounts,
|
|
||||||
$activeHighSeverityFindingCounts,
|
|
||||||
$anyGovernanceFindingCounts,
|
|
||||||
$overdueHighSeverityFindingCounts,
|
|
||||||
$warningExceptionCounts,
|
|
||||||
$criticalExceptionCounts,
|
|
||||||
$reviewPackRequestCounts,
|
|
||||||
$recentReviewPacks,
|
|
||||||
$recentUsageEventCounts,
|
|
||||||
$historicalUsageEventCounts,
|
|
||||||
$resolvedWindow,
|
|
||||||
$now,
|
|
||||||
): array {
|
|
||||||
$workspaceId = (int) $workspace->getKey();
|
|
||||||
/** @var Collection<int, Tenant> $workspaceTenants */
|
|
||||||
$workspaceTenants = $tenantsByWorkspace->get($workspaceId, collect());
|
|
||||||
/** @var TenantOnboardingSession|null $latestOnboardingSession */
|
|
||||||
$latestOnboardingSession = $latestOnboardingSessions->get($workspaceId);
|
|
||||||
/** @var Collection<int, ProviderConnection> $providerConnections */
|
|
||||||
$providerConnections = $providerConnectionsByWorkspace->get($workspaceId, collect());
|
|
||||||
/** @var ReviewPack|null $latestReviewPack */
|
|
||||||
$latestReviewPack = $recentReviewPacks->get($workspaceId);
|
|
||||||
|
|
||||||
$dimensions = $this->buildDimensions(
|
|
||||||
tenants: $workspaceTenants,
|
|
||||||
latestOnboardingSession: $latestOnboardingSession,
|
|
||||||
providerConnections: $providerConnections,
|
|
||||||
recentRunCount: $this->countForWorkspace($recentRunCounts, $workspaceId),
|
|
||||||
recentFailedRunCount: $this->countForWorkspace($recentFailedRunCounts, $workspaceId),
|
|
||||||
recentStuckRunCount: $this->countForWorkspace($recentStuckRunCounts, $workspaceId),
|
|
||||||
activeHighSeverityFindingCount: $this->countForWorkspace($activeHighSeverityFindingCounts, $workspaceId),
|
|
||||||
anyGovernanceFindingCount: $this->countForWorkspace($anyGovernanceFindingCounts, $workspaceId),
|
|
||||||
overdueHighSeverityFindingCount: $this->countForWorkspace($overdueHighSeverityFindingCounts, $workspaceId),
|
|
||||||
warningExceptionCount: $this->countForWorkspace($warningExceptionCounts, $workspaceId),
|
|
||||||
criticalExceptionCount: $this->countForWorkspace($criticalExceptionCounts, $workspaceId),
|
|
||||||
reviewPackRequestCount: $this->countForWorkspace($reviewPackRequestCounts, $workspaceId),
|
|
||||||
latestReviewPack: $latestReviewPack,
|
|
||||||
recentUsageEventCount: $this->countForWorkspace($recentUsageEventCounts, $workspaceId),
|
|
||||||
historicalUsageEventCount: $this->countForWorkspace($historicalUsageEventCounts, $workspaceId),
|
|
||||||
now: $now,
|
|
||||||
);
|
|
||||||
|
|
||||||
$overallLevel = $this->dimensionCatalog->resolveOverallLevel(
|
|
||||||
array_map(static fn (array $dimension): string => $dimension['level'], $dimensions),
|
|
||||||
);
|
|
||||||
|
|
||||||
$dominantDimensionKeys = $this->dominantDimensionKeys($dimensions);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'workspace_name' => (string) $workspace->name,
|
|
||||||
'overall_level' => $overallLevel,
|
|
||||||
'dimensions' => $dimensions,
|
|
||||||
'dominant_dimension_keys' => $dominantDimensionKeys,
|
|
||||||
'non_ok_dimension_count' => count(array_filter(
|
|
||||||
$dimensions,
|
|
||||||
static fn (array $dimension): bool => $dimension['level'] !== 'ok',
|
|
||||||
)),
|
|
||||||
'next_link' => $this->nextLink(
|
|
||||||
workspace: $workspace,
|
|
||||||
tenants: $workspaceTenants,
|
|
||||||
dominantDimensionKeys: $dominantDimensionKeys,
|
|
||||||
window: $resolvedWindow,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_name: string,
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>,
|
|
||||||
* non_ok_dimension_count: int,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* }|null
|
|
||||||
*/
|
|
||||||
public function summaryForWorkspace(Workspace|int $workspace, SystemConsoleWindow|string|null $window = null, ?CarbonImmutable $now = null): ?array
|
|
||||||
{
|
|
||||||
$workspaceId = $workspace instanceof Workspace ? (int) $workspace->getKey() : (int) $workspace;
|
|
||||||
|
|
||||||
/** @var array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_name: string,
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>,
|
|
||||||
* non_ok_dimension_count: int,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* }|null $summary
|
|
||||||
*/
|
|
||||||
$summary = $this->summaries($window, $now)->firstWhere('workspace_id', $workspaceId);
|
|
||||||
|
|
||||||
return $summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{ok: int, warn: int, critical: int, unknown: int}
|
|
||||||
*/
|
|
||||||
public function healthCounts(SystemConsoleWindow|string|null $window = null, ?CarbonImmutable $now = null): array
|
|
||||||
{
|
|
||||||
$counts = [
|
|
||||||
'ok' => 0,
|
|
||||||
'warn' => 0,
|
|
||||||
'critical' => 0,
|
|
||||||
'unknown' => 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($this->summaries($window, $now) as $summary) {
|
|
||||||
$counts[$summary['overall_level']]++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_name: string,
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>,
|
|
||||||
* non_ok_dimension_count: int,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
public function attentionNeeded(SystemConsoleWindow|string|null $window = null, int $limit = 10, ?CarbonImmutable $now = null): Collection
|
|
||||||
{
|
|
||||||
return $this->summaries($window, $now)
|
|
||||||
->filter(static fn (array $summary): bool => $summary['overall_level'] !== 'ok')
|
|
||||||
->sort(function (array $left, array $right): int {
|
|
||||||
$severityComparison = $this->levelRank($right['overall_level']) <=> $this->levelRank($left['overall_level']);
|
|
||||||
|
|
||||||
if ($severityComparison !== 0) {
|
|
||||||
return $severityComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
$nonOkComparison = $right['non_ok_dimension_count'] <=> $left['non_ok_dimension_count'];
|
|
||||||
|
|
||||||
if ($nonOkComparison !== 0) {
|
|
||||||
return $nonOkComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
$nameComparison = strcasecmp($left['workspace_name'], $right['workspace_name']);
|
|
||||||
|
|
||||||
if ($nameComparison !== 0) {
|
|
||||||
return $nameComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $left['workspace_id'] <=> $right['workspace_id'];
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->take(max(1, $limit));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveWindow(SystemConsoleWindow|string|null $window): SystemConsoleWindow
|
|
||||||
{
|
|
||||||
if ($window instanceof SystemConsoleWindow) {
|
|
||||||
return $window;
|
|
||||||
}
|
|
||||||
|
|
||||||
return SystemConsoleWindow::fromNullable(is_string($window) ? $window : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, Tenant> $tenants
|
|
||||||
* @param Collection<int, ProviderConnection> $providerConnections
|
|
||||||
* @return array<string, array{label: string, level: string, windowed: bool}>
|
|
||||||
*/
|
|
||||||
private function buildDimensions(
|
|
||||||
Collection $tenants,
|
|
||||||
?TenantOnboardingSession $latestOnboardingSession,
|
|
||||||
Collection $providerConnections,
|
|
||||||
int $recentRunCount,
|
|
||||||
int $recentFailedRunCount,
|
|
||||||
int $recentStuckRunCount,
|
|
||||||
int $activeHighSeverityFindingCount,
|
|
||||||
int $anyGovernanceFindingCount,
|
|
||||||
int $overdueHighSeverityFindingCount,
|
|
||||||
int $warningExceptionCount,
|
|
||||||
int $criticalExceptionCount,
|
|
||||||
int $reviewPackRequestCount,
|
|
||||||
?ReviewPack $latestReviewPack,
|
|
||||||
int $recentUsageEventCount,
|
|
||||||
int $historicalUsageEventCount,
|
|
||||||
CarbonImmutable $now,
|
|
||||||
): array {
|
|
||||||
$levels = [
|
|
||||||
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $this->onboardingReadinessLevel($tenants, $latestOnboardingSession),
|
|
||||||
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $this->providerConnectionHealthLevel($providerConnections),
|
|
||||||
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => $this->operationalStabilityLevel($recentRunCount, $recentFailedRunCount, $recentStuckRunCount),
|
|
||||||
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $this->governancePressureLevel(
|
|
||||||
activeHighSeverityFindingCount: $activeHighSeverityFindingCount,
|
|
||||||
anyGovernanceFindingCount: $anyGovernanceFindingCount,
|
|
||||||
overdueHighSeverityFindingCount: $overdueHighSeverityFindingCount,
|
|
||||||
warningExceptionCount: $warningExceptionCount,
|
|
||||||
criticalExceptionCount: $criticalExceptionCount,
|
|
||||||
),
|
|
||||||
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => $this->reviewPackReadinessLevel($reviewPackRequestCount, $latestReviewPack, $now),
|
|
||||||
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $this->engagementFreshnessLevel($recentUsageEventCount, $historicalUsageEventCount),
|
|
||||||
];
|
|
||||||
|
|
||||||
$dimensions = [];
|
|
||||||
|
|
||||||
foreach ($this->dimensionCatalog->visibleDimensions() as $dimensionKey => $label) {
|
|
||||||
$dimensions[$dimensionKey] = [
|
|
||||||
'label' => $label,
|
|
||||||
'level' => $levels[$dimensionKey],
|
|
||||||
'windowed' => $this->dimensionCatalog->isWindowed($dimensionKey),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $dimensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, Tenant> $tenants
|
|
||||||
*/
|
|
||||||
private function onboardingReadinessLevel(Collection $tenants, ?TenantOnboardingSession $latestOnboardingSession): string
|
|
||||||
{
|
|
||||||
if ($latestOnboardingSession instanceof TenantOnboardingSession) {
|
|
||||||
return match ($latestOnboardingSession->lifecycleState()) {
|
|
||||||
OnboardingLifecycleState::ReadyForActivation,
|
|
||||||
OnboardingLifecycleState::Completed => 'ok',
|
|
||||||
OnboardingLifecycleState::Cancelled => 'critical',
|
|
||||||
OnboardingLifecycleState::Draft,
|
|
||||||
OnboardingLifecycleState::Verifying,
|
|
||||||
OnboardingLifecycleState::ActionRequired,
|
|
||||||
OnboardingLifecycleState::Bootstrapping => 'warn',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenants->isEmpty()) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenants->contains(static fn (Tenant $tenant): bool => (string) $tenant->status === Tenant::STATUS_ACTIVE)) {
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, ProviderConnection> $providerConnections
|
|
||||||
*/
|
|
||||||
private function providerConnectionHealthLevel(Collection $providerConnections): string
|
|
||||||
{
|
|
||||||
if ($providerConnections->isEmpty()) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($providerConnections->contains(function (ProviderConnection $connection): bool {
|
|
||||||
if (! $connection->is_enabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$consentStatus = $this->normalizeBackedEnumValue($connection->consent_status);
|
|
||||||
$verificationStatus = $this->normalizeBackedEnumValue($connection->verification_status);
|
|
||||||
|
|
||||||
return in_array($consentStatus, [
|
|
||||||
ProviderConsentStatus::Required->value,
|
|
||||||
ProviderConsentStatus::Failed->value,
|
|
||||||
ProviderConsentStatus::Revoked->value,
|
|
||||||
], true) || in_array($verificationStatus, [
|
|
||||||
ProviderVerificationStatus::Blocked->value,
|
|
||||||
ProviderVerificationStatus::Error->value,
|
|
||||||
], true);
|
|
||||||
})) {
|
|
||||||
return 'critical';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($providerConnections->contains(function (ProviderConnection $connection): bool {
|
|
||||||
if (! $connection->is_enabled) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$consentStatus = $this->normalizeBackedEnumValue($connection->consent_status);
|
|
||||||
$verificationStatus = $this->normalizeBackedEnumValue($connection->verification_status);
|
|
||||||
|
|
||||||
return in_array($consentStatus, [
|
|
||||||
ProviderConsentStatus::Unknown->value,
|
|
||||||
], true) || in_array($verificationStatus, [
|
|
||||||
ProviderVerificationStatus::Unknown->value,
|
|
||||||
ProviderVerificationStatus::Pending->value,
|
|
||||||
ProviderVerificationStatus::Degraded->value,
|
|
||||||
], true);
|
|
||||||
})) {
|
|
||||||
return 'warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($providerConnections->contains(function (ProviderConnection $connection): bool {
|
|
||||||
return $connection->is_enabled
|
|
||||||
&& $this->normalizeBackedEnumValue($connection->consent_status) === ProviderConsentStatus::Granted->value
|
|
||||||
&& $this->normalizeBackedEnumValue($connection->verification_status) === ProviderVerificationStatus::Healthy->value;
|
|
||||||
})) {
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function operationalStabilityLevel(int $recentRunCount, int $recentFailedRunCount, int $recentStuckRunCount): string
|
|
||||||
{
|
|
||||||
if ($recentFailedRunCount > 0 || $recentStuckRunCount > 0) {
|
|
||||||
return 'critical';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($recentRunCount > 0) {
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function governancePressureLevel(
|
|
||||||
int $activeHighSeverityFindingCount,
|
|
||||||
int $anyGovernanceFindingCount,
|
|
||||||
int $overdueHighSeverityFindingCount,
|
|
||||||
int $warningExceptionCount,
|
|
||||||
int $criticalExceptionCount,
|
|
||||||
): string {
|
|
||||||
if ($overdueHighSeverityFindingCount > 0 || $criticalExceptionCount > 0) {
|
|
||||||
return 'critical';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($activeHighSeverityFindingCount > 0 || $warningExceptionCount > 0) {
|
|
||||||
return 'warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($anyGovernanceFindingCount === 0 && $warningExceptionCount === 0 && $criticalExceptionCount === 0) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function reviewPackReadinessLevel(int $reviewPackRequestCount, ?ReviewPack $latestReviewPack, CarbonImmutable $now): string
|
|
||||||
{
|
|
||||||
if ($reviewPackRequestCount === 0 && ! $latestReviewPack instanceof ReviewPack) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $latestReviewPack instanceof ReviewPack) {
|
|
||||||
return 'warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(string) $latestReviewPack->status === ReviewPack::STATUS_READY
|
|
||||||
&& (! $latestReviewPack->expires_at instanceof CarbonImmutable || $latestReviewPack->expires_at->gt($now))
|
|
||||||
) {
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
in_array((string) $latestReviewPack->status, [
|
|
||||||
ReviewPack::STATUS_FAILED,
|
|
||||||
ReviewPack::STATUS_EXPIRED,
|
|
||||||
], true)
|
|
||||||
|| ($latestReviewPack->expires_at !== null && $latestReviewPack->expires_at->lte($now))
|
|
||||||
) {
|
|
||||||
return 'critical';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function engagementFreshnessLevel(int $recentUsageEventCount, int $historicalUsageEventCount): string
|
|
||||||
{
|
|
||||||
if ($recentUsageEventCount > 0) {
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($historicalUsageEventCount > 0) {
|
|
||||||
return 'warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, array{label: string, level: string, windowed: bool}> $dimensions
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private function dominantDimensionKeys(array $dimensions): array
|
|
||||||
{
|
|
||||||
$catalogOrder = array_flip($this->dimensionCatalog->names());
|
|
||||||
|
|
||||||
return collect($dimensions)
|
|
||||||
->reject(static fn (array $dimension): bool => $dimension['level'] === 'ok')
|
|
||||||
->keys()
|
|
||||||
->sort(function (string $left, string $right) use ($dimensions, $catalogOrder): int {
|
|
||||||
$severityComparison = $this->levelRank($dimensions[$right]['level']) <=> $this->levelRank($dimensions[$left]['level']);
|
|
||||||
|
|
||||||
if ($severityComparison !== 0) {
|
|
||||||
return $severityComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ($catalogOrder[$left] ?? PHP_INT_MAX) <=> ($catalogOrder[$right] ?? PHP_INT_MAX);
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, Tenant> $tenants
|
|
||||||
* @param list<string> $dominantDimensionKeys
|
|
||||||
* @return array{label: string, url: string}
|
|
||||||
*/
|
|
||||||
private function nextLink(Workspace $workspace, Collection $tenants, array $dominantDimensionKeys, SystemConsoleWindow $window): array
|
|
||||||
{
|
|
||||||
$dominantDimension = $dominantDimensionKeys[0] ?? null;
|
|
||||||
|
|
||||||
if ($dominantDimension === CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY && $this->canOpenRunsLink()) {
|
|
||||||
return [
|
|
||||||
'label' => 'Open runs',
|
|
||||||
'url' => SystemOperationRunLinks::index(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var Tenant|null $tenant */
|
|
||||||
$tenant = $tenants->first();
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
'label' => 'Review health details',
|
|
||||||
'url' => $this->withWindowQuery(SystemDirectoryLinks::tenantDetail($tenant), $window),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'label' => 'Review health details',
|
|
||||||
'url' => $this->withWindowQuery(SystemDirectoryLinks::workspaceDetail($workspace), $window),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function withWindowQuery(string $url, SystemConsoleWindow $window): string
|
|
||||||
{
|
|
||||||
$separator = str_contains($url, '?') ? '&' : '?';
|
|
||||||
|
|
||||||
return $url.$separator.http_build_query(['window' => $window->value]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function canOpenRunsLink(): bool
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
if (! $user instanceof PlatformUser) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
|
||||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<OperationRun>|Builder<ProductUsageEvent>|Builder<ReviewPack>|Builder<Finding>|Builder<FindingException> $query
|
|
||||||
* @return array<int, int>
|
|
||||||
*/
|
|
||||||
private function groupedCounts(Builder $query): array
|
|
||||||
{
|
|
||||||
return $query
|
|
||||||
->selectRaw('workspace_id, COUNT(*) as aggregate')
|
|
||||||
->groupBy('workspace_id')
|
|
||||||
->pluck('aggregate', 'workspace_id')
|
|
||||||
->mapWithKeys(static fn (mixed $count, mixed $workspaceId): array => [
|
|
||||||
(int) $workspaceId => (int) $count,
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, int> $counts
|
|
||||||
*/
|
|
||||||
private function countForWorkspace(array $counts, int $workspaceId): int
|
|
||||||
{
|
|
||||||
return (int) ($counts[$workspaceId] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function levelRank(string $level): int
|
|
||||||
{
|
|
||||||
return match ($level) {
|
|
||||||
'critical' => 4,
|
|
||||||
'warn' => 3,
|
|
||||||
'unknown' => 2,
|
|
||||||
default => 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeBackedEnumValue(mixed $value): string
|
|
||||||
{
|
|
||||||
if (is_object($value) && property_exists($value, 'value')) {
|
|
||||||
return (string) $value->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (string) $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function constrainToActiveTenantTruth(Builder $query): void
|
|
||||||
{
|
|
||||||
$query
|
|
||||||
->whereNull('tenant_id')
|
|
||||||
->orWhereHas('tenant', function (Builder $tenantQuery): void {
|
|
||||||
$tenantQuery
|
|
||||||
->whereNull('deleted_at')
|
|
||||||
->where('status', '!=', Tenant::STATUS_ARCHIVED);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,7 +5,6 @@
|
|||||||
namespace App\Support\Settings;
|
namespace App\Support\Settings;
|
||||||
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
|
||||||
|
|
||||||
final class SettingsRegistry
|
final class SettingsRegistry
|
||||||
{
|
{
|
||||||
@ -219,91 +218,6 @@ 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;
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,129 +0,0 @@
|
|||||||
<?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'],
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,186 +0,0 @@
|
|||||||
<?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,7 +39,6 @@
|
|||||||
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;
|
||||||
@ -662,32 +661,6 @@ 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',
|
||||||
|
|||||||
@ -1,98 +0,0 @@
|
|||||||
<?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' => [],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
@php
|
|
||||||
/** @var array{
|
|
||||||
* overall: array{label: string, color: string, icon: string|null},
|
|
||||||
* reason: string,
|
|
||||||
* impact: string,
|
|
||||||
* recommended_action: string,
|
|
||||||
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
|
|
||||||
* window_label: string
|
|
||||||
* } $decision */
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<x-filament::section>
|
|
||||||
<x-slot name="heading">
|
|
||||||
Customer health decision
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<x-slot name="description">
|
|
||||||
Decision-first summary. Operational stability, review-pack readiness, and engagement freshness honor {{ $decision['window_label'] }}.
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<div class="space-y-5">
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-gray-950 dark:text-white">Overall health</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-filament::badge :color="$decision['overall']['color']" :icon="$decision['overall']['icon']">
|
|
||||||
{{ $decision['overall']['label'] }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<p class="text-sm font-medium text-gray-950 dark:text-white">Reason</p>
|
|
||||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $decision['reason'] }}</p>
|
|
||||||
|
|
||||||
@if ($decision['dominant_dimensions'] !== [])
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
@foreach ($decision['dominant_dimensions'] as $dimension)
|
|
||||||
<x-filament::badge :color="$dimension['color']" :icon="$dimension['icon']">
|
|
||||||
{{ $dimension['label'] }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<p class="text-sm font-medium text-gray-950 dark:text-white">Impact</p>
|
|
||||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $decision['impact'] }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<p class="text-sm font-medium text-gray-950 dark:text-white">Recommended next action</p>
|
|
||||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $decision['recommended_action'] }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
@php
|
@php
|
||||||
/** @var \App\Models\Tenant $tenant */
|
/** @var \App\Models\Tenant $tenant */
|
||||||
$tenant = $this->tenant;
|
$tenant = $this->tenant;
|
||||||
$customerHealthDecision = $this->customerHealthDecision();
|
|
||||||
$providerConnections = $this->providerConnections();
|
$providerConnections = $this->providerConnections();
|
||||||
$permissions = $this->tenantPermissions();
|
$permissions = $this->tenantPermissions();
|
||||||
$runs = $this->recentRuns();
|
$runs = $this->recentRuns();
|
||||||
@ -33,19 +32,11 @@
|
|||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<x-filament::link :href="$this->adminTenantUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
<x-filament::link :href="$this->adminTenantUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
||||||
Open in tenant admin
|
Open in /admin
|
||||||
</x-filament::link>
|
</x-filament::link>
|
||||||
|
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Requires tenant admin membership.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
@if ($customerHealthDecision)
|
|
||||||
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<x-slot name="heading">
|
<x-slot name="heading">
|
||||||
Connectivity signals
|
Connectivity signals
|
||||||
|
|||||||
@ -1,14 +1,8 @@
|
|||||||
@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>
|
||||||
@ -36,62 +30,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
@if ($customerHealthDecision)
|
|
||||||
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@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
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
<x-filament-widgets::widget>
|
|
||||||
<x-filament::section>
|
|
||||||
<x-slot name="heading">
|
|
||||||
Attention-needed workspaces
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<x-slot name="description">
|
|
||||||
Worst derived workspace health first. Operational stability, review-pack readiness, and engagement freshness honor {{ $windowLabel }}.
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
@if ($rows->isEmpty())
|
|
||||||
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-500 dark:border-white/15 dark:text-gray-400">
|
|
||||||
No workspaces need attention in the selected view.
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="space-y-3">
|
|
||||||
@foreach ($rows as $row)
|
|
||||||
<article class="rounded-xl border border-gray-200 bg-white/70 p-4 dark:border-white/10 dark:bg-white/5">
|
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-950 dark:text-white">{{ $row['workspace_label'] }}</h3>
|
|
||||||
|
|
||||||
<x-filament::badge :color="$row['overall']['color']" :icon="$row['overall']['icon']">
|
|
||||||
{{ $row['overall']['label'] }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Top drivers: {{ $row['dominant_copy'] }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
@foreach ($row['dominant_dimensions'] as $dimension)
|
|
||||||
<x-filament::badge :color="$dimension['color']" :icon="$dimension['icon']">
|
|
||||||
{{ $dimension['label'] }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<x-filament::link :href="$row['next_link']['url']" icon="heroicon-m-arrow-top-right-on-square">
|
|
||||||
{{ $row['next_link']['label'] }}
|
|
||||||
</x-filament::link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</x-filament::section>
|
|
||||||
</x-filament-widgets::widget>
|
|
||||||
@ -9,8 +9,6 @@
|
|||||||
/** @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 */
|
||||||
@ -26,12 +24,6 @@
|
|||||||
@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">
|
||||||
@ -45,15 +37,12 @@
|
|||||||
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>
|
||||||
@endif
|
@elseif ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating)
|
||||||
|
|
||||||
@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">
|
||||||
@ -74,9 +63,7 @@
|
|||||||
Started {{ $pack->created_at?->diffForHumans() ?? '—' }}
|
Started {{ $pack->created_at?->diffForHumans() ?? '—' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@elseif ($statusEnum === ReviewPackStatus::Ready)
|
||||||
|
|
||||||
@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">
|
||||||
@ -129,16 +116,13 @@
|
|||||||
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>
|
||||||
@endif
|
@elseif ($statusEnum === ReviewPackStatus::Failed)
|
||||||
|
|
||||||
@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">
|
||||||
@ -179,15 +163,12 @@
|
|||||||
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>
|
||||||
@endif
|
@elseif ($statusEnum === ReviewPackStatus::Expired)
|
||||||
|
|
||||||
@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">
|
||||||
@ -208,7 +189,6 @@
|
|||||||
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,14 +5,12 @@
|
|||||||
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;
|
||||||
@ -242,47 +240,6 @@ 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(),
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
<?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,7 +950,6 @@ 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,
|
||||||
|
|||||||
@ -35,7 +35,6 @@ 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,
|
||||||
@ -68,7 +67,6 @@ 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')
|
||||||
@ -78,7 +76,6 @@ 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,
|
||||||
|
|||||||
@ -1,190 +0,0 @@
|
|||||||
<?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);
|
|
||||||
});
|
|
||||||
@ -1,190 +0,0 @@
|
|||||||
<?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,13 +9,11 @@
|
|||||||
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);
|
||||||
|
|
||||||
@ -23,17 +21,6 @@
|
|||||||
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 {
|
||||||
@ -137,15 +124,11 @@ function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?
|
|||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
$component = Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListReviewPacks::class)
|
->test(ListReviewPacks::class)
|
||||||
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
|
->assertActionVisible('generate_pack')
|
||||||
|
->assertActionDisabled('generate_pack')
|
||||||
$emptyStateAction = getReviewPackRbacEmptyStateAction($component, 'generate_first');
|
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
||||||
|
|
||||||
expect($emptyStateAction)->not->toBeNull()
|
|
||||||
->and($emptyStateAction?->isDisabled())->toBeTrue()
|
|
||||||
->and($emptyStateAction?->getTooltip())->toBe(UiTooltips::insufficientPermission());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── REVIEW_PACK_MANAGE Member ──────────────────────────────
|
// ─── REVIEW_PACK_MANAGE Member ──────────────────────────────
|
||||||
@ -154,12 +137,6 @@ function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?
|
|||||||
$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,19 +13,16 @@
|
|||||||
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);
|
||||||
@ -34,31 +31,6 @@
|
|||||||
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([
|
||||||
@ -158,7 +130,8 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
'tenant_id' => (int) $otherTenant->getKey(),
|
'tenant_id' => (int) $otherTenant->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setTenantPanelContext($tenant);
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListReviewPacks::class)
|
->test(ListReviewPacks::class)
|
||||||
@ -177,112 +150,32 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
->assertSee('No review packs yet');
|
->assertSee('No review packs yet');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── List Page Start CTA Placement ───────────────────────────
|
// ─── List Page Header Action ─────────────────────────────────
|
||||||
|
|
||||||
it('shows generate only in the empty state when no review packs exist', function (): void {
|
it('shows the generate_pack header action for a MANAGE user', 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_first action for a readonly user in the empty state', function (): void {
|
it('disables the generate_pack action for a readonly user', 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);
|
||||||
|
|
||||||
$component = Livewire::actingAs($user)
|
|
||||||
->test(ListReviewPacks::class)
|
|
||||||
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
|
|
||||||
|
|
||||||
$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)
|
Livewire::actingAs($user)
|
||||||
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
|
->test(ListReviewPacks::class)
|
||||||
->assertActionVisible('regenerate')
|
->assertActionVisible('generate_pack')
|
||||||
->assertActionDisabled('regenerate');
|
->assertActionDisabled('generate_pack')
|
||||||
|
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
||||||
});
|
});
|
||||||
|
|
||||||
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 {
|
||||||
@ -332,12 +225,6 @@ 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);
|
||||||
|
|
||||||
@ -349,7 +236,7 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
])
|
])
|
||||||
->assertNotified();
|
->assertNotified();
|
||||||
|
|
||||||
expect(ReviewPack::query()->count())->toBe(1);
|
expect(ReviewPack::query()->count())->toBe(0);
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -11,9 +11,6 @@
|
|||||||
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;
|
||||||
@ -51,13 +48,7 @@ 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);
|
||||||
|
|||||||
@ -1,166 +0,0 @@
|
|||||||
<?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();
|
|
||||||
});
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
<?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);
|
|
||||||
});
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\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);
|
|
||||||
});
|
|
||||||
@ -1,137 +0,0 @@
|
|||||||
<?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);
|
|
||||||
});
|
|
||||||
@ -1,171 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\System\Pages\Dashboard;
|
|
||||||
use App\Filament\System\Widgets\CustomerHealthKpis;
|
|
||||||
use App\Filament\System\Widgets\CustomerHealthTopWorkspaces;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
Filament::setCurrentPanel('system');
|
|
||||||
Filament::bootCurrentPanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows customer health widgets to authorized system users', function (): void {
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user, 'platform')
|
|
||||||
->get(Dashboard::getUrl(panel: 'system'))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSeeLivewire(CustomerHealthKpis::class)
|
|
||||||
->assertSeeLivewire(CustomerHealthTopWorkspaces::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the attention-needed widget hidden when no linked system detail surface is accessible', function (): void {
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user, 'platform')
|
|
||||||
->get(Dashboard::getUrl(panel: 'system'))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSeeLivewire(CustomerHealthKpis::class)
|
|
||||||
->assertDontSeeLivewire(CustomerHealthTopWorkspaces::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the attention-needed widget to operations-only users when operational rows are accessible', function (): void {
|
|
||||||
seedOperationalAttentionWorkspace('Ops Only Workspace');
|
|
||||||
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
PlatformCapabilities::OPERATIONS_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user, 'platform')
|
|
||||||
->get(Dashboard::getUrl(panel: 'system'))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSeeLivewire(CustomerHealthKpis::class)
|
|
||||||
->assertSeeLivewire(CustomerHealthTopWorkspaces::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the attention-needed widget to ops and runbooks users when operational rows are accessible', function (): void {
|
|
||||||
seedOperationalAttentionWorkspace('Runbooks Ops Workspace');
|
|
||||||
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
PlatformCapabilities::OPS_VIEW,
|
|
||||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user, 'platform')
|
|
||||||
->get(Dashboard::getUrl(panel: 'system'))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSeeLivewire(CustomerHealthKpis::class)
|
|
||||||
->assertSeeLivewire(CustomerHealthTopWorkspaces::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters directory-only attention rows out for operations-only users', function (): void {
|
|
||||||
seedOperationalAttentionWorkspace('Accessible Ops Workspace');
|
|
||||||
seedProviderAttentionWorkspace('Directory Only Workspace');
|
|
||||||
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
PlatformCapabilities::OPERATIONS_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user, 'platform')
|
|
||||||
->get(Dashboard::getUrl(panel: 'system'))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSeeLivewire(CustomerHealthKpis::class)
|
|
||||||
->assertSeeLivewire(CustomerHealthTopWorkspaces::class)
|
|
||||||
->assertSee('Accessible Ops Workspace')
|
|
||||||
->assertDontSee('Directory Only Workspace');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forbids customer health widgets when system dashboard access is denied', function (): void {
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user, 'platform')
|
|
||||||
->get(Dashboard::getUrl(panel: 'system'))
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
function seedOperationalAttentionWorkspace(string $workspaceName): void
|
|
||||||
{
|
|
||||||
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'name' => $workspaceName.' Tenant',
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => OperationRunStatus::Queued->value,
|
|
||||||
'outcome' => OperationRunOutcome::Pending->value,
|
|
||||||
'created_at' => now()->subHours(2),
|
|
||||||
'started_at' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedProviderAttentionWorkspace(string $workspaceName): void
|
|
||||||
{
|
|
||||||
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'name' => $workspaceName.' Tenant',
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
'is_enabled' => true,
|
|
||||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
|
||||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@ -1,186 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\System\Pages\Dashboard;
|
|
||||||
use App\Filament\System\Widgets\CustomerHealthKpis;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ProductUsageEvent;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
Filament::setCurrentPanel('system');
|
|
||||||
Filament::bootCurrentPanel();
|
|
||||||
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-04-27 12:00:00'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders aggregate healthy, warning, critical, and unknown counts with a visible time-basis cue', function (): void {
|
|
||||||
actingAsCustomerHealthSystemUser();
|
|
||||||
|
|
||||||
$baselineStats = customerHealthStats(Livewire::withQueryParams([
|
|
||||||
'window' => SystemConsoleWindow::LastDay,
|
|
||||||
])->test(CustomerHealthKpis::class));
|
|
||||||
|
|
||||||
seedCustomerHealthWorkspace('Healthy Workspace');
|
|
||||||
seedCustomerHealthWorkspace('Warning Workspace', recentUsage: false, historicalUsage: true);
|
|
||||||
seedCustomerHealthWorkspace('Critical Workspace', failedRun: true);
|
|
||||||
seedCustomerHealthWorkspace('Unknown Workspace', recentRun: false, readyReviewPack: false);
|
|
||||||
|
|
||||||
$stats = customerHealthStats(Livewire::withQueryParams([
|
|
||||||
'window' => SystemConsoleWindow::LastDay,
|
|
||||||
])->test(CustomerHealthKpis::class));
|
|
||||||
|
|
||||||
expect((int) $stats['Healthy']['value'] - (int) $baselineStats['Healthy']['value'])->toBe(1)
|
|
||||||
->and($stats['Healthy']['description'])->toBe('Operational stability, review-pack readiness, and engagement freshness honor Last 24 hours.')
|
|
||||||
->and((int) $stats['Warning']['value'] - (int) $baselineStats['Warning']['value'])->toBe(1)
|
|
||||||
->and($stats['Warning']['description'])->toBe('Onboarding readiness, provider health, and governance pressure stay point-in-time.')
|
|
||||||
->and((int) $stats['Critical']['value'] - (int) $baselineStats['Critical']['value'])->toBe(1)
|
|
||||||
->and((int) $stats['Unknown']['value'] - (int) $baselineStats['Unknown']['value'])->toBe(1)
|
|
||||||
->and($stats['Unknown']['description'])->toBe('Missing or stale inputs stay explicit instead of silently reading healthy.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers the customer health widget on the system dashboard for authorized users', function (): void {
|
|
||||||
$user = actingAsCustomerHealthSystemUser();
|
|
||||||
|
|
||||||
$response = $this->actingAs($user, 'platform')->get(Dashboard::getUrl(panel: 'system'));
|
|
||||||
|
|
||||||
$response->assertSuccessful()
|
|
||||||
->assertSee('Customer health')
|
|
||||||
->assertSeeLivewire(CustomerHealthKpis::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array{value:string,description:string|null}>
|
|
||||||
*/
|
|
||||||
function customerHealthStats($component): array
|
|
||||||
{
|
|
||||||
$method = new ReflectionMethod(CustomerHealthKpis::class, 'getStats');
|
|
||||||
$method->setAccessible(true);
|
|
||||||
|
|
||||||
return collect($method->invoke($component->instance()))
|
|
||||||
->mapWithKeys(fn (Stat $stat): array => [
|
|
||||||
(string) $stat->getLabel() => [
|
|
||||||
'value' => (string) $stat->getValue(),
|
|
||||||
'description' => $stat->getDescription(),
|
|
||||||
],
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
function actingAsCustomerHealthSystemUser(): PlatformUser
|
|
||||||
{
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
test()->actingAs($user, 'platform');
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{workspace: Workspace, tenant: Tenant}
|
|
||||||
*/
|
|
||||||
function seedCustomerHealthWorkspace(
|
|
||||||
string $workspaceName,
|
|
||||||
bool $readyReviewPack = true,
|
|
||||||
bool $recentUsage = true,
|
|
||||||
bool $historicalUsage = false,
|
|
||||||
bool $recentRun = true,
|
|
||||||
bool $failedRun = false,
|
|
||||||
): array {
|
|
||||||
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'name' => $workspaceName.' Tenant',
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($readyReviewPack) {
|
|
||||||
ReviewPack::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->ready()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'created_at' => now()->subHour(),
|
|
||||||
'generated_at' => now()->subHour(),
|
|
||||||
'expires_at' => now()->addDays(30),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($recentUsage) {
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'occurred_at' => now()->subMinutes(20),
|
|
||||||
]);
|
|
||||||
} elseif ($historicalUsage) {
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'occurred_at' => now()->subDays(3),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($recentRun) {
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => $failedRun ? OperationRunOutcome::Failed->value : OperationRunOutcome::Succeeded->value,
|
|
||||||
'created_at' => now()->subMinutes(10),
|
|
||||||
'started_at' => now()->subMinutes(15),
|
|
||||||
'completed_at' => now()->subMinutes(5),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Finding::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->closed()
|
|
||||||
->create([
|
|
||||||
'severity' => Finding::SEVERITY_LOW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace' => $workspace,
|
|
||||||
'tenant' => $tenant,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ProductUsageEvent;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-04-27 12:00:00'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a decision-first customer health card on the tenant detail page before diagnostics', function (): void {
|
|
||||||
$fixture = createCustomerHealthDecisionFixture('Tenant Decision Workspace');
|
|
||||||
$platformUser = createDirectoryPlatformUser();
|
|
||||||
|
|
||||||
$response = $this->actingAs($platformUser, 'platform')
|
|
||||||
->get(SystemDirectoryLinks::tenantDetail($fixture['tenant']).'?window='.SystemConsoleWindow::LastWeek)
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSee('Customer health decision')
|
|
||||||
->assertSee('Overall health')
|
|
||||||
->assertSee('Reason')
|
|
||||||
->assertSee('Impact')
|
|
||||||
->assertSee('Recommended next action')
|
|
||||||
->assertSee('Top driver: Provider connection health')
|
|
||||||
->assertSee('Default provider consent or verification is blocking reliable tenant management.')
|
|
||||||
->assertSee('Review connectivity signals below and confirm the default provider consent and verification state.')
|
|
||||||
->assertSee('Last 7 days');
|
|
||||||
|
|
||||||
$html = $response->getContent();
|
|
||||||
$decisionPosition = strpos($html, 'Customer health decision');
|
|
||||||
$diagnosticsPosition = strpos($html, 'Connectivity signals');
|
|
||||||
|
|
||||||
expect($decisionPosition)->not->toBeFalse()
|
|
||||||
->and($diagnosticsPosition)->not->toBeFalse()
|
|
||||||
->and($decisionPosition)->toBeLessThan($diagnosticsPosition);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a decision-first customer health card on the workspace detail page before tenant diagnostics', function (): void {
|
|
||||||
$fixture = createCustomerHealthDecisionFixture('Workspace Decision Workspace');
|
|
||||||
$platformUser = createDirectoryPlatformUser();
|
|
||||||
|
|
||||||
$response = $this->actingAs($platformUser, 'platform')
|
|
||||||
->get(SystemDirectoryLinks::workspaceDetail($fixture['workspace']).'?window='.SystemConsoleWindow::LastWeek)
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSee('Customer health decision')
|
|
||||||
->assertSee('Overall health')
|
|
||||||
->assertSee('Reason')
|
|
||||||
->assertSee('Impact')
|
|
||||||
->assertSee('Recommended next action')
|
|
||||||
->assertSee('Top driver: Provider connection health')
|
|
||||||
->assertSee('Default provider consent or verification is blocking reliable tenant management.')
|
|
||||||
->assertSee('Open the affected tenant below and review the default provider connection state.')
|
|
||||||
->assertSee('Last 7 days');
|
|
||||||
|
|
||||||
$html = $response->getContent();
|
|
||||||
$decisionPosition = strpos($html, 'Customer health decision');
|
|
||||||
$diagnosticsPosition = strpos($html, 'Tenants summary');
|
|
||||||
|
|
||||||
expect($decisionPosition)->not->toBeFalse()
|
|
||||||
->and($diagnosticsPosition)->not->toBeFalse()
|
|
||||||
->and($decisionPosition)->toBeLessThan($diagnosticsPosition);
|
|
||||||
});
|
|
||||||
|
|
||||||
function createDirectoryPlatformUser(): PlatformUser
|
|
||||||
{
|
|
||||||
return PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{workspace: Workspace, tenant: Tenant}
|
|
||||||
*/
|
|
||||||
function createCustomerHealthDecisionFixture(string $workspaceName): array
|
|
||||||
{
|
|
||||||
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'name' => $workspaceName.' Tenant',
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
'is_enabled' => true,
|
|
||||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
|
||||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ReviewPack::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->ready()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'created_at' => now()->subDays(3),
|
|
||||||
'generated_at' => now()->subDays(3),
|
|
||||||
'expires_at' => now()->addDays(30),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'occurred_at' => now()->subDays(3),
|
|
||||||
]);
|
|
||||||
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'created_at' => now()->subDays(3),
|
|
||||||
'started_at' => now()->subDays(3)->subMinutes(5),
|
|
||||||
'completed_at' => now()->subDays(3),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Finding::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->closed()
|
|
||||||
->create([
|
|
||||||
'severity' => Finding::SEVERITY_LOW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace' => $workspace,
|
|
||||||
'tenant' => $tenant,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\System\Widgets\CustomerHealthTopWorkspaces;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ProductUsageEvent;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
Filament::setCurrentPanel('system');
|
|
||||||
Filament::bootCurrentPanel();
|
|
||||||
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-04-27 12:00:00'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists the worst workspaces first with dominant reasons and one platform-safe next link per row', function (): void {
|
|
||||||
actingAsExplainabilitySystemUser();
|
|
||||||
|
|
||||||
$opsWorkspace = seedExplainabilityWorkspace('Alpha Operations');
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($opsWorkspace['tenant'])
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $opsWorkspace['workspace']->getKey(),
|
|
||||||
'status' => OperationRunStatus::Queued->value,
|
|
||||||
'outcome' => OperationRunOutcome::Pending->value,
|
|
||||||
'created_at' => now()->subHours(2),
|
|
||||||
'started_at' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$providerWorkspace = seedExplainabilityWorkspace('Beta Provider');
|
|
||||||
ProviderConnection::query()
|
|
||||||
->where('tenant_id', (int) $providerWorkspace['tenant']->getKey())
|
|
||||||
->update([
|
|
||||||
'is_enabled' => true,
|
|
||||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
|
||||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$viewData = customerHealthTopWorkspaceViewData(SystemConsoleWindow::LastWeek);
|
|
||||||
$rows = collect($viewData['rows'])->take(2)->values();
|
|
||||||
|
|
||||||
expect($rows[0]['workspace_label'])->toBe('Alpha Operations')
|
|
||||||
->and($rows[0]['overall']['label'])->toBe('Critical')
|
|
||||||
->and(array_column($rows[0]['dominant_dimensions'], 'label'))->toBe(['Operational stability'])
|
|
||||||
->and($rows[0]['next_link'])->toBe([
|
|
||||||
'label' => 'Open runs',
|
|
||||||
'url' => SystemOperationRunLinks::index(),
|
|
||||||
])
|
|
||||||
->and($rows[1]['workspace_label'])->toBe('Beta Provider')
|
|
||||||
->and($rows[1]['overall']['label'])->toBe('Critical')
|
|
||||||
->and(array_column($rows[1]['dominant_dimensions'], 'label'))->toBe(['Provider connection health'])
|
|
||||||
->and($rows[1]['next_link'])->toBe([
|
|
||||||
'label' => 'Review health details',
|
|
||||||
'url' => SystemDirectoryLinks::tenantDetail($providerWorkspace['tenant']).'?window='.SystemConsoleWindow::LastWeek,
|
|
||||||
])
|
|
||||||
->and($rows->every(fn (array $row): bool => count($row['dominant_dimensions']) <= 2))->toBeTrue()
|
|
||||||
->and($rows->every(fn (array $row): bool => ! str_contains($row['next_link']['url'], '/admin/')))->toBeTrue()
|
|
||||||
->and(array_key_exists('failed_count', $rows[0]))->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
function customerHealthTopWorkspaceViewData(string $window = SystemConsoleWindow::LastDay): array
|
|
||||||
{
|
|
||||||
$component = Livewire::withQueryParams([
|
|
||||||
'window' => $window,
|
|
||||||
])->test(CustomerHealthTopWorkspaces::class);
|
|
||||||
|
|
||||||
$method = new ReflectionMethod(CustomerHealthTopWorkspaces::class, 'getViewData');
|
|
||||||
$method->setAccessible(true);
|
|
||||||
|
|
||||||
return $method->invoke($component->instance());
|
|
||||||
}
|
|
||||||
|
|
||||||
function actingAsExplainabilitySystemUser(): PlatformUser
|
|
||||||
{
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
PlatformCapabilities::OPERATIONS_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
test()->actingAs($user, 'platform');
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{workspace: Workspace, tenant: Tenant}
|
|
||||||
*/
|
|
||||||
function seedExplainabilityWorkspace(string $workspaceName): array
|
|
||||||
{
|
|
||||||
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'name' => $workspaceName.' Tenant',
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ReviewPack::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->ready()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'created_at' => now()->subHour(),
|
|
||||||
'generated_at' => now()->subHour(),
|
|
||||||
'expires_at' => now()->addDays(30),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'occurred_at' => now()->subMinutes(20),
|
|
||||||
]);
|
|
||||||
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'created_at' => now()->subMinutes(10),
|
|
||||||
'started_at' => now()->subMinutes(15),
|
|
||||||
'completed_at' => now()->subMinutes(5),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Finding::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->closed()
|
|
||||||
->create([
|
|
||||||
'severity' => Finding::SEVERITY_LOW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace' => $workspace,
|
|
||||||
'tenant' => $tenant,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -6,8 +6,6 @@
|
|||||||
use App\Filament\System\Widgets\ControlTowerKpis;
|
use App\Filament\System\Widgets\ControlTowerKpis;
|
||||||
use App\Filament\System\Widgets\ControlTowerRecentFailures;
|
use App\Filament\System\Widgets\ControlTowerRecentFailures;
|
||||||
use App\Filament\System\Widgets\ControlTowerTopOffenders;
|
use App\Filament\System\Widgets\ControlTowerTopOffenders;
|
||||||
use App\Filament\System\Widgets\CustomerHealthKpis;
|
|
||||||
use App\Filament\System\Widgets\CustomerHealthTopWorkspaces;
|
|
||||||
use App\Filament\System\Widgets\ProductTelemetryKpis;
|
use App\Filament\System\Widgets\ProductTelemetryKpis;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
@ -74,23 +72,17 @@
|
|||||||
|
|
||||||
$widgets = $component->instance()->getWidgets();
|
$widgets = $component->instance()->getWidgets();
|
||||||
|
|
||||||
expect($widgets)->toHaveCount(7)
|
expect($widgets)->toHaveCount(5)
|
||||||
->and($widgets[1])->toBeInstanceOf(WidgetConfiguration::class)
|
->and($widgets[1])->toBeInstanceOf(WidgetConfiguration::class)
|
||||||
->and($widgets[2])->toBeInstanceOf(WidgetConfiguration::class)
|
->and($widgets[2])->toBeInstanceOf(WidgetConfiguration::class)
|
||||||
->and($widgets[3])->toBeInstanceOf(WidgetConfiguration::class)
|
->and($widgets[3])->toBeInstanceOf(WidgetConfiguration::class)
|
||||||
->and($widgets[4])->toBeInstanceOf(WidgetConfiguration::class)
|
->and($widgets[4])->toBeInstanceOf(WidgetConfiguration::class)
|
||||||
->and($widgets[5])->toBeInstanceOf(WidgetConfiguration::class)
|
->and($widgets[1]->widget)->toBe(ControlTowerKpis::class)
|
||||||
->and($widgets[6])->toBeInstanceOf(WidgetConfiguration::class)
|
->and($widgets[2]->widget)->toBe(ProductTelemetryKpis::class)
|
||||||
->and($widgets[1]->widget)->toBe(CustomerHealthKpis::class)
|
->and($widgets[3]->widget)->toBe(ControlTowerTopOffenders::class)
|
||||||
->and($widgets[2]->widget)->toBe(CustomerHealthTopWorkspaces::class)
|
->and($widgets[4]->widget)->toBe(ControlTowerRecentFailures::class)
|
||||||
->and($widgets[3]->widget)->toBe(ControlTowerKpis::class)
|
|
||||||
->and($widgets[4]->widget)->toBe(ProductTelemetryKpis::class)
|
|
||||||
->and($widgets[5]->widget)->toBe(ControlTowerTopOffenders::class)
|
|
||||||
->and($widgets[6]->widget)->toBe(ControlTowerRecentFailures::class)
|
|
||||||
->and($widgets[1]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
->and($widgets[1]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
||||||
->and($widgets[2]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
->and($widgets[2]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
||||||
->and($widgets[3]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
->and($widgets[3]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
||||||
->and($widgets[4]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
->and($widgets[4]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek]);
|
||||||
->and($widgets[5]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
|
|
||||||
->and($widgets[6]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek]);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -76,8 +76,7 @@
|
|||||||
->assertSee('Residual Directory Workspace')
|
->assertSee('Residual Directory Workspace')
|
||||||
->assertSee('Connectivity signals')
|
->assertSee('Connectivity signals')
|
||||||
->assertSee('Residual Default Connection')
|
->assertSee('Residual Default Connection')
|
||||||
->assertSee('Open in tenant admin')
|
->assertSee('Open in /admin')
|
||||||
->assertSee('Requires tenant admin membership.')
|
|
||||||
->assertSee(SystemDirectoryLinks::adminTenant($tenant), false)
|
->assertSee(SystemDirectoryLinks::adminTenant($tenant), false)
|
||||||
->assertSee('Open operations runs')
|
->assertSee('Open operations runs')
|
||||||
->assertSee(SystemOperationRunLinks::index(), false)
|
->assertSee(SystemOperationRunLinks::index(), false)
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
<?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');
|
|
||||||
});
|
|
||||||
@ -1,155 +0,0 @@
|
|||||||
<?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');
|
|
||||||
});
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
<?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',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Support\CustomerHealth\CustomerHealthDimensionCatalog;
|
|
||||||
|
|
||||||
it('exposes the fixed first-slice health dimensions and their time basis', function (): void {
|
|
||||||
$catalog = new CustomerHealthDimensionCatalog();
|
|
||||||
|
|
||||||
expect($catalog->names())->toBe([
|
|
||||||
CustomerHealthDimensionCatalog::ONBOARDING_READINESS,
|
|
||||||
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH,
|
|
||||||
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY,
|
|
||||||
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE,
|
|
||||||
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS,
|
|
||||||
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS,
|
|
||||||
])->and($catalog->visibleDimensions())->toBe([
|
|
||||||
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => 'Onboarding readiness',
|
|
||||||
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => 'Provider connection health',
|
|
||||||
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => 'Operational stability',
|
|
||||||
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => 'Governance pressure',
|
|
||||||
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => 'Review-pack readiness',
|
|
||||||
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => 'Engagement freshness',
|
|
||||||
])->and($catalog->isWindowed(CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY))->toBeTrue()
|
|
||||||
->and($catalog->isWindowed(CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS))->toBeTrue()
|
|
||||||
->and($catalog->isWindowed(CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS))->toBeTrue()
|
|
||||||
->and($catalog->isWindowed(CustomerHealthDimensionCatalog::ONBOARDING_READINESS))->toBeFalse()
|
|
||||||
->and($catalog->isWindowed(CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH))->toBeFalse()
|
|
||||||
->and($catalog->isWindowed(CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE))->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves the overall health level using the fixed severity precedence', function (): void {
|
|
||||||
$catalog = new CustomerHealthDimensionCatalog();
|
|
||||||
|
|
||||||
expect($catalog->resolveOverallLevel(['ok', 'unknown']))->toBe('unknown')
|
|
||||||
->and($catalog->resolveOverallLevel(['ok', 'warn', 'unknown']))->toBe('warn')
|
|
||||||
->and($catalog->resolveOverallLevel(['critical', 'ok']))->toBe('critical')
|
|
||||||
->and($catalog->resolveOverallLevel(['ok']))->toBe('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unknown customer health dimensions', function (): void {
|
|
||||||
$catalog = new CustomerHealthDimensionCatalog();
|
|
||||||
|
|
||||||
expect(fn () => $catalog->definition('customer_health.unknown'))
|
|
||||||
->toThrow(InvalidArgumentException::class, 'Unknown customer health dimension');
|
|
||||||
});
|
|
||||||
@ -1,414 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ProductUsageEvent;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantOnboardingSession;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\CustomerHealth\CustomerHealthDimensionCatalog;
|
|
||||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
|
||||||
use App\Support\Onboarding\OnboardingLifecycleState;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2025-02-15 12:00:00'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('derives workspace health from existing onboarding, provider, and telemetry truth', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create(['name' => 'Acme']);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'name' => 'Acme Production',
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
'is_enabled' => true,
|
|
||||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
|
||||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'occurred_at' => now()->subHours(2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Finding::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->closed()
|
|
||||||
->create([
|
|
||||||
'severity' => Finding::SEVERITY_LOW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$summary = summaryForWorkspace($workspace);
|
|
||||||
|
|
||||||
expect($summary['overall_level'])->toBe('critical')
|
|
||||||
->and($summary['dimensions'][CustomerHealthDimensionCatalog::ONBOARDING_READINESS]['level'])->toBe('ok')
|
|
||||||
->and($summary['dimensions'][CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH]['level'])->toBe('critical')
|
|
||||||
->and($summary['dimensions'][CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS]['level'])->toBe('ok')
|
|
||||||
->and($summary['dominant_dimension_keys'])->toBe([CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH, CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY, CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS])
|
|
||||||
->and($summary['next_link'])->toBe([
|
|
||||||
'label' => 'Review health details',
|
|
||||||
'url' => SystemDirectoryLinks::tenantDetail($tenant).'?window='.SystemConsoleWindow::LastDay,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies the selected window review-pack readiness rules', function (): void {
|
|
||||||
$unknownWorkspace = createWorkspaceWithActiveTenant('Unknown Workspace');
|
|
||||||
$warnWorkspace = createWorkspaceWithActiveTenant('Warn Workspace');
|
|
||||||
$okWorkspace = createWorkspaceWithActiveTenant('Ok Workspace');
|
|
||||||
$criticalWorkspace = createWorkspaceWithActiveTenant('Critical Workspace');
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($warnWorkspace['tenant'])
|
|
||||||
->forEvent(ProductUsageEventCatalog::REVIEW_PACK_REQUESTED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $warnWorkspace['workspace']->getKey(),
|
|
||||||
'occurred_at' => now()->subHours(1),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ReviewPack::factory()
|
|
||||||
->for($okWorkspace['tenant'])
|
|
||||||
->ready()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $okWorkspace['workspace']->getKey(),
|
|
||||||
'created_at' => now()->subHours(3),
|
|
||||||
'generated_at' => now()->subHours(2),
|
|
||||||
'expires_at' => now()->addDays(30),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ReviewPack::factory()
|
|
||||||
->for($criticalWorkspace['tenant'])
|
|
||||||
->failed()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $criticalWorkspace['workspace']->getKey(),
|
|
||||||
'created_at' => now()->subHours(2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$summaries = app(WorkspaceHealthSummaryQuery::class)
|
|
||||||
->summaries(SystemConsoleWindow::LastDay)
|
|
||||||
->keyBy('workspace_id');
|
|
||||||
|
|
||||||
expect($summaries[(int) $unknownWorkspace['workspace']->getKey()]['dimensions'][CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS]['level'])->toBe('unknown')
|
|
||||||
->and($summaries[(int) $warnWorkspace['workspace']->getKey()]['dimensions'][CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS]['level'])->toBe('warn')
|
|
||||||
->and($summaries[(int) $okWorkspace['workspace']->getKey()]['dimensions'][CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS]['level'])->toBe('ok')
|
|
||||||
->and($summaries[(int) $criticalWorkspace['workspace']->getKey()]['dimensions'][CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS]['level'])->toBe('critical');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the selected window for operations and engagement freshness', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create(['name' => 'Windowed Signals']);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
'name' => 'Windowed Tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'occurred_at' => now()->subDays(3),
|
|
||||||
]);
|
|
||||||
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Failed->value,
|
|
||||||
'created_at' => now()->subDays(2),
|
|
||||||
'started_at' => now()->subDays(2)->subMinutes(5),
|
|
||||||
'completed_at' => now()->subDays(2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$summary = summaryForWorkspace($workspace);
|
|
||||||
|
|
||||||
expect($summary['dimensions'][CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS]['level'])->toBe('warn')
|
|
||||||
->and($summary['dimensions'][CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY]['level'])->toBe('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps governance pressure explicit until governance truth exists', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create(['name' => 'Governance Signals']);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
'name' => 'Governance Tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ReviewPack::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->ready()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'created_at' => now()->subHour(),
|
|
||||||
'generated_at' => now()->subHour(),
|
|
||||||
'expires_at' => now()->addDays(30),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'occurred_at' => now()->subMinutes(20),
|
|
||||||
]);
|
|
||||||
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'created_at' => now()->subMinutes(10),
|
|
||||||
'started_at' => now()->subMinutes(15),
|
|
||||||
'completed_at' => now()->subMinutes(5),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$summaryWithoutGovernanceTruth = summaryForWorkspace($workspace);
|
|
||||||
|
|
||||||
expect($summaryWithoutGovernanceTruth['dimensions'][CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE]['level'])->toBe('unknown');
|
|
||||||
|
|
||||||
Finding::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->closed()
|
|
||||||
->create([
|
|
||||||
'severity' => Finding::SEVERITY_LOW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$summaryWithGovernanceTruth = summaryForWorkspace($workspace);
|
|
||||||
|
|
||||||
expect($summaryWithGovernanceTruth['dimensions'][CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE]['level'])->toBe('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores archived workspaces and archived tenant truth for active workspace summaries', function (): void {
|
|
||||||
$archivedWorkspace = Workspace::factory()->create([
|
|
||||||
'name' => 'Archived Workspace',
|
|
||||||
'archived_at' => now()->subDay(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$archivedWorkspaceTenant = Tenant::factory()->for($archivedWorkspace)->create([
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($archivedWorkspaceTenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $archivedWorkspace->getKey(),
|
|
||||||
'occurred_at' => now()->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$workspace = Workspace::factory()->create(['name' => 'Mixed Workspace']);
|
|
||||||
$activeTenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
'name' => 'Active Tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$archivedTenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'status' => Tenant::STATUS_ARCHIVED,
|
|
||||||
'name' => 'Archived Tenant',
|
|
||||||
'deleted_at' => now()->subDay(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($activeTenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($archivedTenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
'is_enabled' => true,
|
|
||||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
|
||||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$summaries = app(WorkspaceHealthSummaryQuery::class)->summaries();
|
|
||||||
$summary = $summaries->firstWhere('workspace_id', (int) $workspace->getKey());
|
|
||||||
|
|
||||||
expect($summaries->contains(fn (array $item): bool => $item['workspace_id'] === (int) $archivedWorkspace->getKey()))->toBeFalse()
|
|
||||||
->and($summary)->not->toBeNull()
|
|
||||||
->and($summary['dimensions'][CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH]['level'])->toBe('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sorts the attention list by severity, breadth, and name', function (): void {
|
|
||||||
$platformUser = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::CONSOLE_VIEW,
|
|
||||||
PlatformCapabilities::OPERATIONS_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($platformUser, 'platform');
|
|
||||||
|
|
||||||
$criticalWorkspace = Workspace::factory()->create(['name' => 'Critical Workspace']);
|
|
||||||
$criticalTenant = Tenant::factory()->for($criticalWorkspace)->create([
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
'name' => 'Critical Tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($criticalTenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $criticalWorkspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($criticalTenant)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $criticalWorkspace->getKey(),
|
|
||||||
'status' => OperationRunStatus::Queued->value,
|
|
||||||
'outcome' => OperationRunOutcome::Pending->value,
|
|
||||||
'created_at' => now()->subHours(2),
|
|
||||||
'started_at' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$warnWorkspace = Workspace::factory()->create(['name' => 'Warn Workspace']);
|
|
||||||
$warnTenant = Tenant::factory()->for($warnWorkspace)->create([
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
'name' => 'Warn Tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($warnTenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $warnWorkspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProductUsageEvent::factory()
|
|
||||||
->for($warnTenant)
|
|
||||||
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $warnWorkspace->getKey(),
|
|
||||||
'occurred_at' => now()->subDays(5),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$unknownWorkspace = Workspace::factory()->create(['name' => 'Unknown Workspace']);
|
|
||||||
|
|
||||||
TenantOnboardingSession::factory()
|
|
||||||
->forWorkspace($unknownWorkspace)
|
|
||||||
->create([
|
|
||||||
'lifecycle_state' => OnboardingLifecycleState::Draft->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$attention = app(WorkspaceHealthSummaryQuery::class)
|
|
||||||
->attentionNeeded(SystemConsoleWindow::LastDay, 3)
|
|
||||||
->values();
|
|
||||||
|
|
||||||
expect($attention->pluck('workspace_name')->all())->toBe([
|
|
||||||
'Critical Workspace',
|
|
||||||
'Unknown Workspace',
|
|
||||||
'Warn Workspace',
|
|
||||||
])
|
|
||||||
->and($attention[0]['next_link'])->toBe([
|
|
||||||
'label' => 'Open runs',
|
|
||||||
'url' => SystemOperationRunLinks::index(),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{workspace: Workspace, tenant: Tenant}
|
|
||||||
*/
|
|
||||||
function createWorkspaceWithActiveTenant(string $workspaceName): array
|
|
||||||
{
|
|
||||||
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
|
|
||||||
$tenant = Tenant::factory()->for($workspace)->create([
|
|
||||||
'name' => $workspaceName.' Tenant',
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderConnection::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->verifiedHealthy()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace' => $workspace,
|
|
||||||
'tenant' => $tenant,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_name: string,
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>,
|
|
||||||
* non_ok_dimension_count: int,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
function summaryForWorkspace(Workspace $workspace): array
|
|
||||||
{
|
|
||||||
/** @var array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* workspace_name: string,
|
|
||||||
* overall_level: string,
|
|
||||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
|
||||||
* dominant_dimension_keys: list<string>,
|
|
||||||
* non_ok_dimension_count: int,
|
|
||||||
* next_link: array{label: string, url: string}
|
|
||||||
* } $summary
|
|
||||||
*/
|
|
||||||
$summary = app(WorkspaceHealthSummaryQuery::class)
|
|
||||||
->summaryForWorkspace($workspace, SystemConsoleWindow::LastDay);
|
|
||||||
|
|
||||||
return $summary;
|
|
||||||
}
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\StoredReport;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
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\SupportRequests\SupportRequestContextBuilder;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
it('builds deterministic canonical context with omission markers when diagnostics are not attached', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create(['name' => 'Omission Tenant']);
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
|
||||||
|
|
||||||
$builder = app(SupportRequestContextBuilder::class);
|
|
||||||
|
|
||||||
$first = $builder->forTenant($tenant, $user, false);
|
|
||||||
$second = $builder->forTenant($tenant->fresh(), $user, false);
|
|
||||||
$sections = collect($first['canonical_context']['sections'])->keyBy('key');
|
|
||||||
|
|
||||||
expect($first)
|
|
||||||
->toEqual($second)
|
|
||||||
->and($first['attachment_mode'])
|
|
||||||
->toBe(SupportRequestContextBuilder::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY)
|
|
||||||
->and($first['diagnostic_snapshot'])
|
|
||||||
->toBeNull()
|
|
||||||
->and(data_get($first, 'primary_context.type'))
|
|
||||||
->toBe('tenant')
|
|
||||||
->and(data_get($first, 'omissions.0.reason'))
|
|
||||||
->toBe('omitted_without_support_diagnostics_view')
|
|
||||||
->and(data_get($sections->get('provider_connection'), 'references.0.label'))
|
|
||||||
->toBe('Provider connection not observed')
|
|
||||||
->and(data_get($sections->get('operation_context'), 'references.0.label'))
|
|
||||||
->toBe('Operation not yet observed')
|
|
||||||
->and(data_get($sections->get('audit_history'), 'references.0.label'))
|
|
||||||
->toBe('Audit event not yet observed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('attaches a redacted diagnostic snapshot without raw payload content', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create(['name' => 'Redaction 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' => 'Redaction Connection',
|
|
||||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
|
||||||
'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
|
|
||||||
'last_error_message' => 'raw-provider-secret-message',
|
|
||||||
'last_health_check_at' => now()->subMinutes(15),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$run = OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'type' => OperationRunType::BaselineCompare->value,
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Failed->value,
|
|
||||||
'summary_counts' => [
|
|
||||||
'total' => 0,
|
|
||||||
'processed' => 0,
|
|
||||||
],
|
|
||||||
'context' => [
|
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
|
||||||
'raw_response_body' => 'secret-provider-body',
|
|
||||||
],
|
|
||||||
'failure_summary' => [[
|
|
||||||
'message' => 'Run failed after provider validation.',
|
|
||||||
]],
|
|
||||||
'completed_at' => now()->subMinutes(10),
|
|
||||||
]);
|
|
||||||
|
|
||||||
StoredReport::factory()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
|
||||||
'payload' => [
|
|
||||||
'raw_response_body' => 'stored-report-secret-body',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
AuditLog::query()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'operation_run_id' => (int) $run->getKey(),
|
|
||||||
'action' => 'operation.failed',
|
|
||||||
'resource_type' => 'operation_run',
|
|
||||||
'resource_id' => (string) $run->getKey(),
|
|
||||||
'target_label' => 'Operation #'.$run->getKey(),
|
|
||||||
'metadata' => [
|
|
||||||
'raw_response_body' => 'audit-secret-body',
|
|
||||||
],
|
|
||||||
'outcome' => 'success',
|
|
||||||
'recorded_at' => now()->subMinutes(5),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$builder = app(SupportRequestContextBuilder::class);
|
|
||||||
$envelope = $builder->forOperationRun($run, $user, true);
|
|
||||||
$encoded = json_encode($envelope, JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
expect($envelope['attachment_mode'])
|
|
||||||
->toBe(SupportRequestContextBuilder::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED)
|
|
||||||
->and(data_get($envelope, 'primary_context.type'))
|
|
||||||
->toBe('operation_run')
|
|
||||||
->and(data_get($envelope, 'primary_context.operation_run_id'))
|
|
||||||
->toBe((int) $run->getKey())
|
|
||||||
->and(data_get($envelope, 'diagnostic_snapshot.redaction.mode'))
|
|
||||||
->toBe('default_redacted')
|
|
||||||
->and(data_get($envelope, 'diagnostic_snapshot.sections.0.key'))
|
|
||||||
->toBe('overview')
|
|
||||||
->and($encoded)
|
|
||||||
->not->toContain('raw-provider-secret-message')
|
|
||||||
->and($encoded)
|
|
||||||
->not->toContain('secret-provider-body')
|
|
||||||
->and($encoded)
|
|
||||||
->not->toContain('stored-report-secret-body')
|
|
||||||
->and($encoded)
|
|
||||||
->not->toContain('audit-secret-body');
|
|
||||||
});
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Support\SupportRequests\SupportRequestReferenceGenerator;
|
|
||||||
|
|
||||||
it('generates unique uppercase support references', function (): void {
|
|
||||||
$generator = new SupportRequestReferenceGenerator();
|
|
||||||
|
|
||||||
$first = $generator->generate();
|
|
||||||
$second = $generator->generate();
|
|
||||||
|
|
||||||
expect($first)
|
|
||||||
->toMatch('/^SR-[0-9A-HJKMNP-TV-Z]{26}$/')
|
|
||||||
->and($second)
|
|
||||||
->toMatch('/^SR-[0-9A-HJKMNP-TV-Z]{26}$/')
|
|
||||||
->and($first)
|
|
||||||
->not->toBe($second);
|
|
||||||
});
|
|
||||||
@ -5,7 +5,7 @@ # Spec Candidates
|
|||||||
>
|
>
|
||||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||||
|
|
||||||
> **Last reviewed**: 2026-04-27 (added Audited Support Sessions / Assisted Tenant Access and Audience-Aware Decision Surface Adoption Closure; retained Product Scalability & Self-Service Foundation, Codebase Quality & Engineering Maturity cluster, Microsoft-first provider-extensible Decision-Based Operating candidates, and Private AI Execution & Usage Governance Foundation candidates)
|
> **Last reviewed**: 2026-04-25 (added Product Scalability & Self-Service Foundation candidates, Additional Solo-Founder Scale Guardrails candidates, Microsoft-first provider-extensible Decision-Based Operating candidates, and Private AI Execution & Usage Governance Foundation candidates; retained Codebase Quality & Engineering Maturity cluster and existing strategic hardening lanes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -725,46 +725,6 @@ ### System Panel Least-Privilege Capability Model
|
|||||||
- **Strategic sequencing**: First item in this cluster because it is the only finding with direct enterprise security / least-privilege implications.
|
- **Strategic sequencing**: First item in this cluster because it is the only finding with direct enterprise security / least-privilege implications.
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
### Audited Support Sessions / Assisted Tenant Access
|
|
||||||
- **Type**: security hardening / platform-plane-to-tenant-plane access boundary
|
|
||||||
- **Source**: product candidate 2026-04-27 — explicit separation between Platform Control Plane (`/system`) and Customer/Tenant Admin Plane (`/admin`)
|
|
||||||
- **Problem**: Platform operators sometimes need tenant-context visibility for support, but the current control-plane/admin-plane split makes a plain `Open in /admin` affordance misleading for platform-only users. Granting permanent tenant memberships or hidden cross-tenant superuser access would solve the support friction in the wrong way by collapsing least privilege, auditability, and customer trust.
|
|
||||||
- **Why it matters**: TenantPilot needs an enterprise-safe answer to "how can support look at a tenant?" The answer cannot be silent impersonation or blanket `/admin` access. It must be explicit, tenant-scoped, reason-bound, time-limited, visible in the UI, and fully auditable.
|
|
||||||
- **Proposed direction**:
|
|
||||||
- introduce a `support_sessions` model bound to one workspace/tenant, one platform user, one mode, one reason, and one expiry
|
|
||||||
- allow support sessions to be started only from `/system`, never implicitly from `/admin`
|
|
||||||
- make read-only the default and smallest promotable slice; keep elevated support as a separately capability-gated follow-up or tightly bounded extension
|
|
||||||
- show a persistent non-dismissible `/admin` banner with workspace/tenant, platform user, mode, reason, expiry, and `End session`
|
|
||||||
- thread `support_session_id` and support-context metadata into audit events and any allowed elevated mutations
|
|
||||||
- replace ambiguous `Open in /admin` affordances with decision-based copy: real tenant membership -> `Open in tenant admin`; support-capable platform user -> `Start support session`; otherwise -> `Admin access requires tenant membership`
|
|
||||||
- enforce expiry and scope server-side so a session cannot cross tenants or remain valid after `expires_at`
|
|
||||||
- **Candidate capabilities**:
|
|
||||||
- `platform.support_sessions.view`
|
|
||||||
- `platform.support_sessions.start`
|
|
||||||
- `platform.support_sessions.end`
|
|
||||||
- `platform.support_sessions.end_any`
|
|
||||||
- `platform.support_sessions.elevate`
|
|
||||||
- `platform.support_sessions.audit_view`
|
|
||||||
- **Scope boundaries**:
|
|
||||||
- **In scope**: support-session model/status lifecycle, required reason, duration defaults/limits, start/end/expiry workflow, visible `/admin` support banner, read-only enforcement for support sessions, audit events, link/CTA semantics, and tests for isolation and expiry
|
|
||||||
- **Out of scope**: silent impersonation as a tenant user, permanent platform-user tenant memberships, Entra-side membership automation, full customer approval workflow, recording/screenshots, or broad privileged write access by default
|
|
||||||
- **Acceptance points**:
|
|
||||||
- a platform user still has no automatic `/admin` access without real tenant membership or an active support session
|
|
||||||
- a support-capable platform user can start a time-limited session for one target workspace/tenant with a mandatory reason
|
|
||||||
- read-only support sessions can open tenant/admin pages and inspect relevant records but cannot trigger mutations such as restore, settings change, exception creation, membership change, or other tenant-changing actions
|
|
||||||
- the `/admin` banner remains visible for the full session and shows enough information to make the access state unmistakable
|
|
||||||
- ended or expired sessions immediately lose access server-side and emit explicit audit events
|
|
||||||
- tests prove a support session cannot be reused for another tenant or hidden behind UI-only checks
|
|
||||||
- **Risks / open questions**:
|
|
||||||
- v1 should likely stay read-only to avoid collapsing this candidate into a broader privileged-write workflow
|
|
||||||
- Elevated support may be necessary later, but it needs its own capability, shorter expiry, stronger audit semantics, and likely separate prioritization
|
|
||||||
- The product must decide whether some diagnostics/support surfaces belong in `/system` instead of requiring tenant-plane access at all
|
|
||||||
- Support-session audit records must remain visibly distinct from real customer-user actions
|
|
||||||
- **Dependencies**: System Panel Least-Privilege Capability Model, tenant/admin access boundary, platform-user capability model, audit log foundation, workspace/tenant scoping helpers, support-diagnostics surfaces
|
|
||||||
- **Related specs / candidates**: Support Diagnostic Pack, In-App Support Request with Context, Operational Controls & Feature Flags, Security Trust Pack Light, platform superadmin / break-glass rules
|
|
||||||
- **Strategic sequencing**: Immediately after System Panel least-privilege hardening. First shrink coarse platform visibility, then introduce an explicit audited bridge for rare tenant-context support access instead of relying on hidden superuser semantics.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Static Analysis Baseline for Platform Code
|
### Static Analysis Baseline for Platform Code
|
||||||
- **Type**: quality gate / developer experience hardening
|
- **Type**: quality gate / developer experience hardening
|
||||||
- **Source**: full codebase quality audit 2026-04-25 — the repo has strong Pest and lane-based tests but no visible PHPStan/Larastan/Psalm/Rector gate
|
- **Source**: full codebase quality audit 2026-04-25 — the repo has strong Pest and lane-based tests but no visible PHPStan/Larastan/Psalm/Rector gate
|
||||||
@ -914,13 +874,12 @@ ### RestoreService Responsibility Split
|
|||||||
|
|
||||||
> Recommended sequence for this cluster:
|
> Recommended sequence for this cluster:
|
||||||
> 1. **System Panel Least-Privilege Capability Model**
|
> 1. **System Panel Least-Privilege Capability Model**
|
||||||
> 2. **Audited Support Sessions / Assisted Tenant Access**
|
> 2. **Static Analysis Baseline for Platform Code**
|
||||||
> 3. **Static Analysis Baseline for Platform Code**
|
> 3. **Architecture Boundary Guard Tests**
|
||||||
> 4. **Architecture Boundary Guard Tests**
|
> 4. **Filament Hotspot Decomposition Foundation**
|
||||||
> 5. **Filament Hotspot Decomposition Foundation**
|
> 5. **RestoreService Responsibility Split**
|
||||||
> 6. **RestoreService Responsibility Split**
|
|
||||||
>
|
>
|
||||||
> Why this order: first close the coarse platform-capability gap, then add an explicit audited bridge for rare tenant-context support access, then add quality gates, then protect architecture boundaries, and only then start behavior-preserving decomposition of the largest UI/service hotspots. This avoids a broad rewrite while directly addressing the audit's highest-leverage security and maintainability risks.
|
> Why this order: first close the enterprise security/least-privilege gap, then add quality gates, then protect architecture boundaries, and only then start behavior-preserving decomposition of the largest UI/service hotspots. This avoids a broad rewrite while directly addressing the audit's highest-leverage risks.
|
||||||
|
|
||||||
|
|
||||||
> Platform Hardening — OperationRun UX Consistency cluster: these candidates prevent OperationRun-starting features from drifting into surface-local UX behavior. The goal is not to rebuild the Operations Hub, progress system, or notification architecture in one step. The immediate priority is to make OperationRun start UX contract-driven so new features cannot hand-roll local toasts, operation links, browser events, and queued-notification decisions independently.
|
> Platform Hardening — OperationRun UX Consistency cluster: these candidates prevent OperationRun-starting features from drifting into surface-local UX behavior. The goal is not to rebuild the Operations Hub, progress system, or notification architecture in one step. The immediate priority is to make OperationRun start UX contract-driven so new features cannot hand-roll local toasts, operation links, browser events, and queued-notification decisions independently.
|
||||||
@ -1653,153 +1612,6 @@ ### Surface Taxonomy & Workflow-First IA Classification
|
|||||||
- **Dependencies**: Decision-First Operating Constitution Hardening, existing navigation/context/action-surface specs, product surface inventory
|
- **Dependencies**: Decision-First Operating Constitution Hardening, existing navigation/context/action-surface specs, product surface inventory
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
### Audience-Aware Decision Surface Adoption Closure
|
|
||||||
- **Priority**: P0
|
|
||||||
- **Type**: UX architecture adoption / platform hardening
|
|
||||||
- **Roadmap fit**: Cross-cutting platform quality, customer-readiness, MSP operator UX, customer read-only foundation
|
|
||||||
- **Depends on**: Existing `EnterpriseDetail`, `OperatorExplanation`, `GovernanceArtifactTruth`, `VerificationReportViewer`, `SupportDiagnosticBundle`, RBAC/capability model
|
|
||||||
- **Do not build**: A parallel UI framework
|
|
||||||
- **Problem**: TenantPilot already has strong shared UI foundations for decision-grade detail pages, governance artifact truth, operator explanations, verification reports, and support diagnostics, but adoption is inconsistent across the platform. Several operational and governance pages still expose too much internal diagnostic, lifecycle, context, reason, JSON, and support/debug information in the default reading path. The recurring issue is not missing data quality. It is information hierarchy: decision content, diagnostics, evidence, and raw support/debug payloads are often rendered as equal-priority blocks.
|
|
||||||
- **Goal**: Harden the existing decision-first UI system by introducing audience-aware disclosure rules and applying them to the highest-risk surfaces first. Default pages should become customer-readable while retaining full operator and support depth through progressive disclosure and role/capability gates.
|
|
||||||
- **Non-goals**:
|
|
||||||
- Do not introduce a new UI framework.
|
|
||||||
- Do not replace `EnterpriseDetail`.
|
|
||||||
- Do not remove diagnostics or raw evidence.
|
|
||||||
- Do not weaken auditability.
|
|
||||||
- Do not hide evidence from authorized operators/support users.
|
|
||||||
- Do not redesign the whole platform in one pass.
|
|
||||||
- Do not migrate every page in this spec.
|
|
||||||
- **Existing foundations to reuse**:
|
|
||||||
- `EnterpriseDetail`
|
|
||||||
- `OperatorExplanation`
|
|
||||||
- `GovernanceArtifactTruth`
|
|
||||||
- `VerificationReportViewer`
|
|
||||||
- `SupportDiagnosticBundle`
|
|
||||||
- existing Filament-native Sections/Infolists/Actions/Tabs/Accordions
|
|
||||||
- **Reuse rule**: Any new helper must be a small visibility/disclosure layer, not a competing rendering system.
|
|
||||||
- **First slice**:
|
|
||||||
1. `OperationRun` viewer
|
|
||||||
2. Managed Tenant Onboarding verify step
|
|
||||||
- **Requirements**:
|
|
||||||
- **Decision surface**: each target page MUST show a clear default decision surface with status, reason, impact, primary next action, one dominant CTA, optional one secondary CTA, and a short artifact/result summary.
|
|
||||||
- **Diagnostics**: lifecycle, timing, related operation, verification-report detail, drift/report detail, supporting evidence, and provider diagnostic summaries MUST be secondary, collapsed, tabbed, or visually lower priority.
|
|
||||||
- **Support / raw evidence**: raw JSON, context payloads, fingerprints, reason owner, platform reason family, viewer context, tenant selector context, monitoring detail, and copy/show-raw actions MUST NOT appear in the default customer-readable path. They must be collapsed and capability-gated where applicable.
|
|
||||||
- **Audience modes**: the target surfaces MUST distinguish customer/read-only default, operator/MSP diagnostics, and platform/support raw evidence. Customer/read-only users MUST NOT see internal debug semantics by default. Operators MAY expand diagnostics. Support/platform users MAY access raw evidence when authorized.
|
|
||||||
- **One primary action**: each target surface MUST expose only one dominant next action. Secondary links such as `Open operation`, `View tenant`, `Technical details`, or `Show JSON` must not visually compete with the primary remediation action.
|
|
||||||
- **No duplicate truth**: the same blocker, reason, or next action MUST NOT be repeated across multiple visible cards. If the dominant blocker is `Admin consent required`, the page may show it once in the decision surface and then provide supporting evidence in diagnostics, but it must not repeat that message in readiness, permission diagnostics, contextual help, verification summary, and issue lists at equal priority.
|
|
||||||
- **OperationRun viewer target state**:
|
|
||||||
- **Default visible**: operation status/outcome, human-readable reason, customer-readable impact, primary next action, artifact/result summary, limited actions
|
|
||||||
- **Secondary / collapsed**: lifecycle, timings, related context, support diagnostics, verification/drift/report internals
|
|
||||||
- **Support/raw gated**: raw context, JSON, fingerprints, reason ownership, platform reason family, monitoring detail
|
|
||||||
- **Managed Tenant Onboarding verify-step target state**:
|
|
||||||
- **Default visible**: onboarding readiness, current step, status, dominant blocker, primary next action, supporting evidence links
|
|
||||||
- **Secondary**: verification summary, required permissions, operation link, technical details
|
|
||||||
- **Hidden / fallback**: permission diagnostics should be visible only as fallback when no stored verification report is available. Once a verification report exists, permission details move into supporting evidence or technical details.
|
|
||||||
- **Acceptance criteria**:
|
|
||||||
- `OperationRun` viewer default path is readable in under 5 seconds.
|
|
||||||
- `OperationRun` viewer shows one dominant next action.
|
|
||||||
- `OperationRun` viewer default path does not show raw JSON, raw context, fingerprints, reason owner, platform reason family, or monitoring detail.
|
|
||||||
- `OperationRun` diagnostics remain accessible to authorized operators.
|
|
||||||
- `OperationRun` raw/support details are collapsed and capability-gated where applicable.
|
|
||||||
- Managed Tenant Onboarding verify step shows exactly one primary next action.
|
|
||||||
- Managed Tenant Onboarding verify step does not duplicate permission/consent blockers across readiness, diagnostics, contextual help, and report sections.
|
|
||||||
- Permission diagnostics are hidden when a stored verification report exists and visible only as fallback when no report exists.
|
|
||||||
- `Current checkpoint` or other internal lifecycle wording is replaced with customer/operator-friendly wording such as `Step`.
|
|
||||||
- Duplicate visible headings such as `Verification report / Verification report` are removed.
|
|
||||||
- Existing support diagnostics and verification report data remain available.
|
|
||||||
- **Required tests**:
|
|
||||||
- focused Pest coverage proving the default view shows status, reason, impact, and primary next action
|
|
||||||
- internal debug semantics are not default-visible
|
|
||||||
- raw JSON/context/fingerprints are collapsed or gated
|
|
||||||
- customer/read-only role does not see support/raw details by default
|
|
||||||
- operator role can access diagnostics
|
|
||||||
- support/platform role can access raw evidence where authorized
|
|
||||||
- onboarding verify step renders one primary action
|
|
||||||
- onboarding permission diagnostics are fallback-only when a verification report exists
|
|
||||||
- no duplicate visible decision headings exist
|
|
||||||
- **Implementation notes**:
|
|
||||||
- Prefer small, composable changes.
|
|
||||||
- Add a visibility/disclosure helper only if existing policies are insufficient.
|
|
||||||
- Extend existing EnterpriseDetail/Verification/Support surfaces instead of replacing them.
|
|
||||||
- Reduce one-off Blade/Tailwind cards where shared patterns can express the same concept.
|
|
||||||
- Preserve auditability and evidence depth.
|
|
||||||
- Preserve existing RBAC/capability enforcement.
|
|
||||||
- Preserve Livewire v4 and Filament v5 conventions.
|
|
||||||
- **Out of scope**:
|
|
||||||
- full platform-wide migration
|
|
||||||
- customer read-only portal implementation
|
|
||||||
- PDF/export redesign
|
|
||||||
- redesign of all Findings/Baseline/Evidence pages
|
|
||||||
- new AI explanation features
|
|
||||||
- new support ticketing workflow
|
|
||||||
|
|
||||||
> Later / dependent candidates: after the adoption-closure slice above is specified and implemented, keep the next migrations explicitly dependent instead of bundling them into the same P0 effort.
|
|
||||||
|
|
||||||
#### Later / dependent candidates
|
|
||||||
|
|
||||||
##### Findings & Risk Acceptance Decision Surface Migration
|
|
||||||
- **Priority**: P1
|
|
||||||
- **Type**: pattern adoption / workflow UX consolidation
|
|
||||||
- **Depends on**: Audience-Aware Decision Surface Adoption Closure
|
|
||||||
- **Roadmap fit**: R1.5 Findings Workflow V2, R1.6 Exceptions / Risk Acceptance, R2 Customer Read-only View
|
|
||||||
- **Problem**: Finding and Finding Exception detail pages currently expose workflow status, ownership, severity, timestamps, exception state, and remediation context through local Filament sections. These pages are functional, but they do not consistently use the shared decision-first detail patterns. As Findings and Risk Acceptance become customer-facing governance workflows, the default view must clearly separate risk decision, owner/action, diagnostic detail, and evidence/support context.
|
|
||||||
- **Goal**: Migrate Finding and Finding Exception detail pages toward the shared decision surface model.
|
|
||||||
- **First slice**:
|
|
||||||
- Finding detail page
|
|
||||||
- Finding Exception detail page
|
|
||||||
- **Requirements**:
|
|
||||||
- default view shows risk status, severity, reason, impact, owner, due date, and next action
|
|
||||||
- workflow actions are clearly prioritized
|
|
||||||
- risk acceptance state is shown as a decision state, not just metadata
|
|
||||||
- evidence and occurrence history are secondary
|
|
||||||
- raw/internal context is hidden or collapsed
|
|
||||||
- customer/read-only users see risk posture and accepted-risk status without internal debug semantics
|
|
||||||
- **Acceptance criteria**:
|
|
||||||
- Finding detail page has one clear primary action based on status
|
|
||||||
- Finding Exception page clearly shows accepted risk, owner, expiry, scope, and renewal/expiry state
|
|
||||||
- evidence/history is secondary
|
|
||||||
- no raw/debug payload appears in the default customer-readable view
|
|
||||||
|
|
||||||
##### Baseline & Drift Decision Surface Migration
|
|
||||||
- **Priority**: P1
|
|
||||||
- **Type**: pattern adoption / governance UX consolidation
|
|
||||||
- **Depends on**: Audience-Aware Decision Surface Adoption Closure
|
|
||||||
- **Roadmap fit**: R1 Golden Master Governance, R1.4 Drift UI, R2 Reports/Evidence
|
|
||||||
- **Problem**: Baseline and Drift pages contain decision-grade governance data but can become dense because comparison state, drift findings, policy metadata, diagnostics, and technical evidence compete in the same reading path.
|
|
||||||
- **Goal**: Apply the decision-first hierarchy to Baseline Profile, Baseline Snapshot, and Baseline Compare surfaces.
|
|
||||||
- **First slice**:
|
|
||||||
- Baseline Profile view
|
|
||||||
- Baseline Compare landing / matrix summary
|
|
||||||
- **Requirements**:
|
|
||||||
- default view shows baseline status, last compare result, drift summary, severity, and primary next action
|
|
||||||
- detailed matrix data is secondary
|
|
||||||
- technical comparison diagnostics are collapsed or moved behind filters/tabs
|
|
||||||
- customer-readable summaries are separated from operator investigation tools
|
|
||||||
- raw comparison context and fingerprints are not default-visible
|
|
||||||
- **Acceptance criteria**:
|
|
||||||
- Baseline Profile view shows one primary governance action
|
|
||||||
- Baseline Compare default view summarizes drift before showing the dense matrix
|
|
||||||
- drift details remain accessible to operators
|
|
||||||
- raw/fingerprint-level data is hidden or support-only
|
|
||||||
|
|
||||||
##### Customer Read-only Decision Views
|
|
||||||
- **Priority**: P2
|
|
||||||
- **Type**: customer-facing UX / access model
|
|
||||||
- **Depends on**: Audience-Aware Decision Surface Adoption Closure, Findings & Risk Acceptance Migration, Reports/Evidence foundation
|
|
||||||
- **Roadmap fit**: R2.6 Customer Read-only View v1
|
|
||||||
- **Problem**: TenantPilot has strong operator and support detail surfaces, but customer-facing users need a calmer read-only experience focused on governance status, accepted risks, findings, evidence summaries, and review outcomes.
|
|
||||||
- **Goal**: Introduce customer read-only views that reuse the same decision-surface contracts but hide operator diagnostics and support/raw internals by default.
|
|
||||||
- **Requirements**:
|
|
||||||
- customers see baseline status, findings, accepted risks, reports, and evidence summaries
|
|
||||||
- customers do not see raw JSON, internal reason ownership, fingerprints, platform reason families, or debug context
|
|
||||||
- customers can open review-ready explanations and evidence summaries
|
|
||||||
- admin/operator actions are hidden
|
|
||||||
- support/operator users keep access to deeper diagnostics
|
|
||||||
- **Acceptance criteria**:
|
|
||||||
- customer members can understand tenant posture without technical debug details
|
|
||||||
- customer views remain audit-friendly but not overwhelming
|
|
||||||
- all customer-facing detail pages follow decision-first, diagnostics-second, evidence-third
|
|
||||||
|
|
||||||
### Personal Work IA / My Work
|
### Personal Work IA / My Work
|
||||||
- **Type**: IA / workflow foundation
|
- **Type**: IA / workflow foundation
|
||||||
- **Source**: admin workspace IA discussion 2026-04-21; personal work architecture candidate pack
|
- **Source**: admin workspace IA discussion 2026-04-21; personal work architecture candidate pack
|
||||||
|
|||||||
@ -4,7 +4,7 @@ # Product Standards
|
|||||||
> Specs reference these standards; they do not redefine them.
|
> Specs reference these standards; they do not redefine them.
|
||||||
> Guard tests enforce critical constraints automatically.
|
> Guard tests enforce critical constraints automatically.
|
||||||
|
|
||||||
**Last reviewed**: 2026-04-27
|
**Last reviewed**: 2026-04-24
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -42,7 +42,7 @@ ## Related Docs
|
|||||||
|
|
||||||
| Document | Location | Purpose |
|
| Document | Location | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Constitution | `.specify/memory/constitution.md` | Permanent principles (PROP-001, BLOAT-001, OPS-UX-START-001, UI-CONST-001, DECIDE-001, DECIDE-AUD-001, UI-SURF-001, ACTSURF-001, UI-HARD-001, UI-EX-001, HDR-001, OPSURF-001, UI-FIL-001, UX-001, Action Surface Contract, RBAC-UX) |
|
| Constitution | `.specify/memory/constitution.md` | Permanent principles (PROP-001, BLOAT-001, OPS-UX-START-001, UI-CONST-001, DECIDE-001, UI-SURF-001, ACTSURF-001, UI-HARD-001, UI-EX-001, HDR-001, OPSURF-001, UI-FIL-001, UX-001, Action Surface Contract, RBAC-UX) |
|
||||||
| Product Principles | `docs/product/principles.md` | High-level product decisions |
|
| Product Principles | `docs/product/principles.md` | High-level product decisions |
|
||||||
| Table Rollout Audit | `docs/ui/filament-table-standard.md` | Rollout inventory and implementation state from Spec 125 |
|
| Table Rollout Audit | `docs/ui/filament-table-standard.md` | Rollout inventory and implementation state from Spec 125 |
|
||||||
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |
|
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
# Specification Quality Checklist: Customer Health Score
|
|
||||||
|
|
||||||
**Purpose**: Validate specification completeness and implementation readiness before the feature moves into the implementation loop
|
|
||||||
**Created**: 2026-04-27
|
|
||||||
**Feature**: [spec.md](../spec.md)
|
|
||||||
|
|
||||||
## Content Quality
|
|
||||||
|
|
||||||
- [x] Business value and operator outcomes stay explicit
|
|
||||||
- [x] The first slice is bounded to derived health summaries on the existing `/system` dashboard
|
|
||||||
- [x] Runtime-governance sections are present for an implementation-ready package
|
|
||||||
- [x] All mandatory sections are completed
|
|
||||||
|
|
||||||
## Requirement Completeness
|
|
||||||
|
|
||||||
- [x] No `[NEEDS CLARIFICATION]` markers remain
|
|
||||||
- [x] Requirements are testable and unambiguous
|
|
||||||
- [x] Acceptance scenarios are defined for the primary user journeys
|
|
||||||
- [x] Edge cases are identified
|
|
||||||
- [x] Scope is clearly bounded away from CRM, billing, predictive scoring, and customer-facing health portals
|
|
||||||
- [x] Dependencies and assumptions are identified
|
|
||||||
|
|
||||||
## Feature Readiness
|
|
||||||
|
|
||||||
- [x] The first slice is small enough for a bounded implementation loop
|
|
||||||
- [x] The plan identifies concrete repo surfaces likely to change
|
|
||||||
- [x] The tasks are ordered, testable, and grouped by user story
|
|
||||||
- [x] Foundational work includes the core unknown-handling and review-pack-readiness rules before dashboard adoption
|
|
||||||
- [x] No unresolved product question blocks safe implementation of the first slice
|
|
||||||
|
|
||||||
## Governance Readiness
|
|
||||||
|
|
||||||
- [x] No new persistence is introduced without justification
|
|
||||||
- [x] Provider-boundary handling and platform-safe deep-link rules are explicit
|
|
||||||
- [x] Existing RBAC and platform-plane access remain authoritative
|
|
||||||
- [x] Operator-facing surface changes include the required UI contract sections
|
|
||||||
- [x] The UI Action Matrix is populated for the modified Filament dashboard surface
|
|
||||||
- [x] The package explicitly requires one platform-safe next link per attention-needed row plus a visible time-basis cue
|
|
||||||
- [x] Default-visible content stays operator-first while raw or support-grade detail remains behind existing linked surfaces
|
|
||||||
- [x] The package avoids duplicate truth by keeping health derived-only and by reusing centralized `SystemHealth` semantics
|
|
||||||
- [x] Livewire v4 compliance, unchanged provider registration location, no global-search changes, no destructive-action additions, and no asset-strategy changes are explicit in the package
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- This checklist completes the implementation-ready package alongside `spec.md`, `plan.md`, and `tasks.md`.
|
|
||||||
- The active slice stays bounded to one derived customer-health support path, two system dashboard widgets, fixed first-slice dimensions, and focused unit plus feature proof only.
|
|
||||||
@ -1,227 +0,0 @@
|
|||||||
# Implementation Plan: Customer Health Score
|
|
||||||
|
|
||||||
**Branch**: `245-customer-health-score` | **Date**: 2026-04-27 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/245-customer-health-score/spec.md`
|
|
||||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/245-customer-health-score/spec.md`
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- Add one bounded `CustomerHealth` support namespace that derives workspace-health summaries from existing onboarding, provider, telemetry, `OperationRun`, findings, and review-pack truth.
|
|
||||||
- Reuse the existing `/system` dashboard, existing dashboard window selector, and existing system widget family to show both aggregate health counts and an attention-needed workspace list.
|
|
||||||
- Reuse the existing system tenant and workspace residual detail pages as decision-first follow-up surfaces by adding one read-only customer-health card above the existing diagnostics.
|
|
||||||
- Keep the slice derived-only, Livewire v4-compatible, Filament v5-native, and free of migrations, new persistence, new panel providers, global-search changes, destructive actions, or asset changes.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
|
||||||
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `ProductTelemetrySummaryQuery`, `ProviderConnectionStateProjector`, `StuckRunClassifier`, `BadgeRenderer`, `SystemConsoleWindow`, system directory links, and system operations links
|
|
||||||
**Storage**: N/A - no new persisted health truth
|
|
||||||
**Testing**: Pest unit + feature tests only
|
|
||||||
**Validation Lanes**: fast-feedback, confidence
|
|
||||||
**Target Platform**: Sail-backed Laravel admin panel under `/system`
|
|
||||||
**Project Type**: web
|
|
||||||
**Performance Goals**: derive health summaries inline for the current system dashboard without background jobs, remote calls, or broad per-tenant page reconstruction
|
|
||||||
**Constraints**: no new score table, no customer-facing surface, no CRM or billing workflow expansion, no direct `/admin` deep links, and no new badge taxonomy
|
|
||||||
**Scale/Scope**: six fixed first-slice dimensions, one derived summary query, two dashboard widgets, one shared decision-first follow-up card on the existing system detail pages, and focused unit plus feature proof only
|
|
||||||
|
|
||||||
## First-Slice Dimension Inventory
|
|
||||||
|
|
||||||
The first slice is locked to these six dimensions only:
|
|
||||||
|
|
||||||
1. **Onboarding readiness** — point-in-time signal derived from existing onboarding-readiness truth
|
|
||||||
2. **Provider connection health** — point-in-time signal derived from existing provider consent and verification truth
|
|
||||||
3. **Operational stability** — windowed signal derived from failed and stuck `OperationRun` truth using the selected `SystemConsoleWindow`
|
|
||||||
4. **Governance pressure** — point-in-time signal derived from active high-severity, overdue, or exception-warning findings truth
|
|
||||||
5. **Review-pack readiness** — narrow mixed signal derived from recent review-pack request context plus the latest relevant `ReviewPack` state
|
|
||||||
6. **Engagement freshness** — windowed signal derived from existing `product_usage_events`
|
|
||||||
|
|
||||||
Any change to this dimension inventory requires an explicit spec update before implementation expands or swaps the slice.
|
|
||||||
|
|
||||||
## Health-Level Resolution Rules
|
|
||||||
|
|
||||||
- Reuse existing `SystemHealth` levels only: `ok`, `warn`, `critical`, `unknown`
|
|
||||||
- Overall level precedence is fixed for v1: `critical` > `warn` > `unknown` > `ok`
|
|
||||||
- Windowed dimensions must honor the selected dashboard time window
|
|
||||||
- Point-in-time dimensions must not pretend to share the same time basis as windowed dimensions
|
|
||||||
- Missing or stale source truth must remain explicit and must never silently collapse to `ok`
|
|
||||||
- Archived workspaces are excluded from active counts, and archived tenants do not contribute source truth into active workspace-health derivation
|
|
||||||
- Attention-needed workspace ordering is fixed for v1: overall severity desc, non-`ok` dimension count desc, workspace name asc, then workspace id asc
|
|
||||||
|
|
||||||
## Review-Pack Readiness Rule
|
|
||||||
|
|
||||||
`Recent` means the currently selected dashboard window. Request context comes from existing review-pack request telemetry when available, falling back to a `ReviewPack` created for the same workspace inside that same window.
|
|
||||||
|
|
||||||
| Condition | Review-pack readiness level | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| No request context and no relevant pack activity in the selected window | `unknown` | The first slice does not infer periodic review obligations from silence |
|
|
||||||
| Request context exists in the selected window and no relevant pack is usable yet | `warn` | Covers queued, running, or not-yet-materialized review-pack generation |
|
|
||||||
| Latest relevant pack in the selected window is ready and not expired | `ok` | A usable recent review pack exists |
|
|
||||||
| Latest relevant pack in the selected window is failed or expired | `critical` | Recent review-pack work ended unusably and needs attention |
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Plan
|
|
||||||
|
|
||||||
- **Guardrail scope**: changed surfaces
|
|
||||||
- **Native vs custom classification summary**: native Filament dashboard plus existing custom system widget family and the existing residual system detail pages
|
|
||||||
- **Shared-family relevance**: dashboard signals/cards, compact attention list, system-safe deep links, existing `SystemHealth` badges
|
|
||||||
- **State layers in scope**: page, widget, window query, detail-link state, residual detail follow-up state
|
|
||||||
- **Handling modes by drift class or surface**: review-mandatory
|
|
||||||
- **Repository-signal treatment**: review-mandatory
|
|
||||||
- **Special surface test profiles**: standard-native-filament
|
|
||||||
- **Required tests or manual smoke**: functional-core, state-contract, follow-up-detail smoke
|
|
||||||
- **Exception path and spread control**: none
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **List-surface review standard**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/docs/product/standards/list-surface-review-checklist.md` applies to `CustomerHealthTopWorkspaces`; accepted compact-widget exceptions are no persistence trio, no bulk actions, no row click, and no empty-state CTA
|
|
||||||
|
|
||||||
## Shared Pattern & System Fit
|
|
||||||
|
|
||||||
- **Cross-cutting feature marker**: yes
|
|
||||||
- **Systems touched**: `App\Filament\System\Pages\Dashboard`, `App\Filament\System\Pages\Directory\ViewTenant`, `App\Filament\System\Pages\Directory\ViewWorkspace`, `App\Filament\System\Widgets\ControlTowerHealthIndicator`, `App\Filament\System\Widgets\ControlTowerTopOffenders`, `App\Support\ProductTelemetry\ProductTelemetrySummaryQuery`, `App\Services\Providers\ProviderConnectionStateProjector`, `App\Support\SystemConsole\StuckRunClassifier`, existing `Finding` truth, existing `ReviewPack` truth, and platform-plane system link helpers
|
|
||||||
- **Shared abstractions reused**: dashboard widget composition, dashboard time-window semantics, `BadgeRenderer` for `SystemHealth`, system-safe link helpers, existing residual system detail pages, and existing source-truth owners for provider, telemetry, operations, findings, and review packs
|
|
||||||
- **New abstraction introduced? why?**: one bounded `CustomerHealth` support namespace with a fixed dimension catalog and one derived summary query is justified because the repo already has isolated source truths but no shared workspace-health derivation path
|
|
||||||
- **Why the existing abstraction was sufficient or insufficient**: existing abstractions are sufficient for rendering and navigation but insufficient for deriving one explainable cross-domain workspace summary
|
|
||||||
- **Bounded deviation / spread control**: no page-local health arithmetic, no second badge language, no persistence, and no customer-success surface. All health derivation must converge on the single `CustomerHealth` namespace.
|
|
||||||
- **Platform-safe link contract**: every attention-needed workspace row must expose exactly one platform-safe next link. If a more specific platform-plane route is not appropriate, the row falls back to the existing system tenant-detail context instead of rendering with no link. Dashboard links into residual detail pages must preserve the selected time window so the follow-up card explains the same top drivers as the dashboard.
|
|
||||||
|
|
||||||
## OperationRun UX Impact
|
|
||||||
|
|
||||||
- **Touches OperationRun start/completion/link UX?**: no
|
|
||||||
- **Central contract reused**: N/A
|
|
||||||
- **Delegated UX behaviors**: N/A
|
|
||||||
- **Surface-owned behavior kept local**: N/A
|
|
||||||
- **Queued DB-notification policy**: N/A
|
|
||||||
- **Terminal notification path**: N/A
|
|
||||||
- **Exception path**: none
|
|
||||||
|
|
||||||
## Provider Boundary & Portability Fit
|
|
||||||
|
|
||||||
- **Shared provider/platform boundary touched?**: yes
|
|
||||||
- **Provider-owned seams**: provider consent and verification outcomes on `ProviderConnection`
|
|
||||||
- **Platform-core seams**: workspace-health dimensions, overall `SystemHealth` level, dominant reason labels, dashboard widget copy, and system-safe next links
|
|
||||||
- **Neutral platform terms / contracts preserved**: customer health, workspace health, attention needed, operational stability, engagement freshness, review readiness
|
|
||||||
- **Retained provider-specific semantics and why**: Microsoft verification and consent states remain provider-owned inputs because they are existing current-release truth already modeled on provider connections
|
|
||||||
- **Bounded extraction or follow-up path**: `document-in-feature` only if one provider-specific dominant reason still needs text-only explanation instead of a platform-safe link in v1
|
|
||||||
|
|
||||||
## Constitution Check
|
|
||||||
|
|
||||||
*GATE: Must pass before implementation begins. Re-check after design changes.*
|
|
||||||
|
|
||||||
- Inventory-first / snapshots-second: PASS - the slice derives health from existing observed truths only
|
|
||||||
- Read/write separation: PASS - the slice is read-only
|
|
||||||
- Graph contract path: PASS - no new Graph calls are added
|
|
||||||
- RBAC-UX / workspace isolation / tenant isolation: PASS - system dashboard access rules remain authoritative and no tenant/admin viewer is introduced
|
|
||||||
- Shared pattern reuse / `XCUT-001`: PASS - dashboard widget family, badge semantics, and system link helpers are reused explicitly
|
|
||||||
- Proportionality / `PROP-001` and `ABSTR-001`: PASS - one bounded derived-query path is the narrowest reusable solution
|
|
||||||
- Persisted truth / `PERSIST-001`: PASS - no new persistence is introduced
|
|
||||||
- UI semantics / `UI-SEM-001`: PASS - the slice adds decision-support summaries only and does not create a new workflow hub
|
|
||||||
- Filament-native UI / `UI-FIL-001`: PASS - the operator-facing impact stays on the existing dashboard widget family
|
|
||||||
- Livewire v4 / Filament v5: PASS - the slice remains fully inside the current Filament v5 + Livewire v4 stack
|
|
||||||
- Provider registration location: PASS - no provider registration changes are introduced; Laravel 11+ provider registration stays in `bootstrap/providers.php`
|
|
||||||
- Global search rule: PASS - no resource or global-search changes are introduced
|
|
||||||
- Destructive actions: PASS - none added
|
|
||||||
- Asset strategy: PASS - no new assets are required, so deployment behavior for `filament:assets` remains unchanged
|
|
||||||
- Test governance / `TEST-GOV-001`: PASS - proof remains in focused unit + feature lanes only
|
|
||||||
|
|
||||||
## Test Governance Check
|
|
||||||
|
|
||||||
- **Test purpose / classification by changed surface**: Unit for dimension rules and summary derivation; Feature for `/system` dashboard rendering, explainability, authorization, and residual detail follow-up rendering
|
|
||||||
- **Affected validation lanes**: fast-feedback, confidence
|
|
||||||
- **Why this lane mix is the narrowest sufficient proof**: the slice is server-driven, read-only, and widget-based; browser automation would duplicate what focused unit and feature tests already prove
|
|
||||||
- **Narrowest proving command(s)**:
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/CustomerHealthDimensionCatalogTest.php tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/CustomerHealth/CustomerHealthDashboardWidgetsTest.php tests/Feature/System/CustomerHealth/CustomerHealthExplainabilityTest.php tests/Feature/System/CustomerHealth/CustomerHealthAuthorizationTest.php`
|
|
||||||
- **Fixture / helper / factory / seed / context cost risks**: reuse existing workspaces, tenants, provider connections, onboarding sessions, telemetry events, operation runs, findings, and review packs; avoid browser setup and avoid new heavy support fixtures
|
|
||||||
- **Expensive defaults or shared helper growth introduced?**: no
|
|
||||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
|
||||||
- **Surface-class relief / special coverage rule**: standard-native-filament relief
|
|
||||||
- **Closing validation and reviewer handoff**: reviewers should confirm unknown handling, dominant reason explainability, platform-safe linking, absence of new persistence, and explicit selected-window semantics
|
|
||||||
- **Disclosure-ladder proof**: reviewers should also confirm that default-visible dashboard content stays operator-first, raw or support-grade detail remains behind linked surfaces, and the two widgets do not duplicate the same visible decision summary
|
|
||||||
- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep
|
|
||||||
- **Review-stop questions**: did the implementation add a persisted score model, customer-facing route, or second badge taxonomy; do unknown and recent-review-pack-request cases degrade honestly?
|
|
||||||
- **Escalation path**: `reject-or-split` if the slice expands into CRM, billing, predictive scoring, or a new portfolio workbench page
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Test-governance outcome**: keep
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Documentation (this feature)
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/245-customer-health-score/
|
|
||||||
├── checklists/
|
|
||||||
│ └── requirements.md
|
|
||||||
├── spec.md
|
|
||||||
├── plan.md
|
|
||||||
└── tasks.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source Code (repository root)
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/platform/
|
|
||||||
├── app/
|
|
||||||
│ ├── Filament/
|
|
||||||
│ │ └── System/
|
|
||||||
│ │ ├── Pages/
|
|
||||||
│ │ │ ├── Dashboard.php
|
|
||||||
│ │ │ └── Directory/
|
|
||||||
│ │ │ ├── ViewTenant.php
|
|
||||||
│ │ │ └── ViewWorkspace.php
|
|
||||||
│ │ └── Widgets/
|
|
||||||
│ │ ├── CustomerHealthKpis.php
|
|
||||||
│ │ └── CustomerHealthTopWorkspaces.php
|
|
||||||
│ ├── Services/
|
|
||||||
│ │ └── Providers/ProviderConnectionStateProjector.php
|
|
||||||
│ └── Support/
|
|
||||||
│ ├── CustomerHealth/
|
|
||||||
│ │ ├── CustomerHealthDimensionCatalog.php
|
|
||||||
│ │ └── WorkspaceHealthSummaryQuery.php
|
|
||||||
│ ├── ProductTelemetry/ProductTelemetrySummaryQuery.php
|
|
||||||
│ └── SystemConsole/
|
|
||||||
├── resources/views/filament/system/pages/directory/
|
|
||||||
│ ├── view-tenant.blade.php
|
|
||||||
│ └── view-workspace.blade.php
|
|
||||||
├── resources/views/filament/system/widgets/customer-health-top-workspaces.blade.php
|
|
||||||
└── tests/
|
|
||||||
├── Unit/Support/CustomerHealth/
|
|
||||||
│ ├── CustomerHealthDimensionCatalogTest.php
|
|
||||||
│ └── WorkspaceHealthSummaryQueryTest.php
|
|
||||||
└── Feature/System/CustomerHealth/
|
|
||||||
├── CustomerHealthDetailDecisionTest.php
|
|
||||||
├── CustomerHealthAuthorizationTest.php
|
|
||||||
├── CustomerHealthDashboardWidgetsTest.php
|
|
||||||
└── CustomerHealthExplainabilityTest.php
|
|
||||||
```
|
|
||||||
|
|
||||||
**Structure Decision**: Single Laravel web application. The implementation adds one bounded support namespace and two dashboard widgets only.
|
|
||||||
|
|
||||||
## Complexity Tracking
|
|
||||||
|
|
||||||
No constitution violations are required. The slice adds one derived summary path only and introduces no new persistence, no new state family, and no new workflow surface.
|
|
||||||
|
|
||||||
## Rollout & Risk Controls
|
|
||||||
|
|
||||||
- Start on the existing `/system` dashboard only; do not introduce a new portfolio or customer-health page in v1.
|
|
||||||
- Keep the first-slice dimension inventory fixed at six. Any added dimension requires a spec update.
|
|
||||||
- Keep review-pack readiness narrow and operational, not programmatic or compliance-heavy.
|
|
||||||
- Use explicit copy or visual grouping to distinguish point-in-time signals from windowed signals.
|
|
||||||
- Guarantee one platform-safe next link per attention-needed workspace row by falling back to the existing system tenant-detail context when a more specific platform-plane route is not appropriate.
|
|
||||||
|
|
||||||
## Implementation Outline
|
|
||||||
|
|
||||||
- Add `App\Support\CustomerHealth\CustomerHealthDimensionCatalog` as the single source for first-slice dimension labels, order, and level precedence rules.
|
|
||||||
- Add `App\Support\CustomerHealth\WorkspaceHealthSummaryQuery` to derive per-workspace summaries from existing onboarding, provider, telemetry, operations, findings, and review-pack truth.
|
|
||||||
- Add `App\Filament\System\Widgets\CustomerHealthKpis` for aggregate health counts.
|
|
||||||
- Add `App\Filament\System\Widgets\CustomerHealthTopWorkspaces` plus a small Blade view for the attention-needed list.
|
|
||||||
- Add one read-only customer-health decision card to the existing system tenant and workspace residual detail pages using the existing summary query and the same dominant drivers as the dashboard.
|
|
||||||
- Register both widgets on the existing `App\Filament\System\Pages\Dashboard` and reuse existing system-safe link helpers only.
|
|
||||||
|
|
||||||
## Implementation Phases
|
|
||||||
|
|
||||||
1. **Foundation**: fixed dimension catalog + derived workspace summary query with core unknown-handling and review-pack-readiness rules + unit proof
|
|
||||||
2. **Dashboard summary**: aggregate KPI widget + system dashboard registration + feature proof
|
|
||||||
3. **Attention list**: compact unhealthy-workspace widget + system-safe links + explainability proof
|
|
||||||
4. **Detail follow-up**: residual system tenant/workspace detail card + preserved window context + follow-up rendering proof
|
|
||||||
5. **Safety hardening**: archived workspace plus tenant exclusions, deterministic ordering hardening, and authorization proof
|
|
||||||
|
|
||||||
## Constitution Check (Post-Design)
|
|
||||||
|
|
||||||
Re-check result: PASS. The plan stays derived-only, reuses existing system dashboard and health presentation semantics, keeps Filament v5 + Livewire v4 intact, leaves provider registration in `bootstrap/providers.php` untouched, introduces no global-search or asset changes, adds no destructive actions, and keeps proof in narrow unit + feature lanes only.
|
|
||||||
@ -1,339 +0,0 @@
|
|||||||
# Feature Specification: Customer Health Score
|
|
||||||
|
|
||||||
**Feature Branch**: `245-customer-health-score`
|
|
||||||
**Created**: 2026-04-27
|
|
||||||
**Status**: Ready for implementation
|
|
||||||
**Input**: User description: "Promote the roadmap-fit candidate Customer Health Score as a narrow, implementation-ready slice that derives an explainable workspace health summary from existing telemetry, onboarding readiness, provider connection health, operation outcomes, findings pressure, and review-pack readiness, then surfaces one attention-oriented summary on the existing system dashboard without introducing CRM, predictive scoring, billing collection, or customer-facing account-management workflows."
|
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
|
||||||
|
|
||||||
- **Problem**: TenantPilot still requires founder-style manual inspection across onboarding, provider connections, failed runs, findings, review packs, and telemetry to understand which workspaces actually need attention.
|
|
||||||
- **Today's failure**: The product exposes isolated health clues, but no explainable workspace-level summary. Operators must reconstruct risk by hopping between `/system`, tenant directory, run lists, and tenant surfaces, which delays support and masks silent customer/workspace deterioration.
|
|
||||||
- **User-visible improvement**: A platform operator can open the existing `/system` dashboard and immediately see which workspaces are healthy, which need attention, why they need attention, and which existing platform-safe surface to open next. When the operator follows that link into the existing system tenant or workspace detail, the page repeats the decision-first health explanation above the existing diagnostics.
|
|
||||||
- **Smallest enterprise-capable version**: Add one derived customer-health query path with six fixed first-slice dimensions, reuse the existing system dashboard and window selector, show aggregate health counts plus an attention-needed workspace list, and keep the entire slice read-only and non-persistent.
|
|
||||||
- **Explicit non-goals**: No CRM or customer-success suite, no billing collection or entitlement enforcement, no predictive churn model, no customer-facing health portal, no tenant/admin-plane health viewer, no new persisted score table, no background recomputation job, and no AI-generated account-management actions.
|
|
||||||
- **Permanent complexity imported**: One bounded `CustomerHealth` support namespace, one derived summary query path, one small fixed dimension catalog, two dashboard widgets, and focused unit plus feature coverage.
|
|
||||||
- **Why now**: Self-Service Tenant Onboarding & Connection Readiness, Support Diagnostic Pack, Operational Controls, Product Usage & Adoption Telemetry, and Product Knowledge are now specced or implemented. Customer Health Score is the smallest next slice that turns those foundations into one operator-facing attention signal before lifecycle communication, AI assistance, or broader portfolio workflows expand.
|
|
||||||
- **Why not local**: Local counters on the system dashboard or one-off health badges in the directory would either duplicate logic across surfaces or hide how onboarding, provider, operational, governance, review-pack, and adoption truth combine into one explainable workspace summary.
|
|
||||||
- **Approval class**: Core Enterprise
|
|
||||||
- **Red flags triggered**: New abstraction, many-signal summary. Defense: the slice stays derived-only, reuses the existing `SystemHealth` level language, fixes the first-slice dimension inventory at six signals, and limits UI impact to the existing `/system` dashboard.
|
|
||||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
|
||||||
- **Decision**: approve
|
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
|
||||||
|
|
||||||
- **Scope**: platform, workspace, tenant
|
|
||||||
- **Primary Routes**:
|
|
||||||
- `/system` existing system dashboard for aggregate health counts and the attention-needed workspace list
|
|
||||||
- existing platform-safe deep links from those widgets into the system tenant directory and system operations surfaces
|
|
||||||
- existing `/system/directory/tenants/{tenant}` and `/system/directory/workspaces/{workspace}` residual detail pages as read-only health follow-up surfaces
|
|
||||||
- **Data Ownership**: No new persisted customer-health truth is introduced. The summary is derived from existing tenant-owned product truths such as `product_usage_events`, `provider_connections`, `operation_runs`, `findings`, `review_packs`, and onboarding-readiness state already owned by existing onboarding/session/provider records.
|
|
||||||
- **RBAC**: Reads remain platform-plane only through the existing `/system` dashboard access gate. No tenant/admin-plane or customer-facing health surface is introduced in this slice.
|
|
||||||
|
|
||||||
For canonical-view specs, the spec MUST define:
|
|
||||||
|
|
||||||
- **Default filter behavior when tenant-context is active**: N/A - the first slice is a platform-plane dashboard addition under `/system`, not a tenant-context admin view.
|
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: The slice exposes derived counts and system-safe drilldown links only to existing platform users who can already access the system dashboard. It does not expose raw tenant-owned health rows or create any cross-plane deep link into `/admin` tenant surfaces.
|
|
||||||
|
|
||||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
|
||||||
|
|
||||||
- **Cross-cutting feature?**: yes
|
|
||||||
- **Interaction class(es)**: dashboard signals/cards, read-only attention list, system-safe deep links, status badges
|
|
||||||
- **Systems touched**: `App\Filament\System\Pages\Dashboard`, existing system dashboard widget family, `App\Support\ProductTelemetry\ProductTelemetrySummaryQuery`, `App\Services\Providers\ProviderConnectionStateProjector`, `App\Support\SystemConsole\StuckRunClassifier`, existing `Finding` and `ReviewPack` truth queries, and existing system link helpers
|
|
||||||
- **Existing pattern(s) to extend**: the `/system` dashboard widget composition, the existing dashboard time-window selector, `ControlTowerHealthIndicator`, and `ControlTowerTopOffenders`
|
|
||||||
- **Shared contract / presenter / builder / renderer to reuse**: `StatsOverviewWidget`, existing custom system widgets, `BadgeRenderer` with `BadgeDomain::SystemHealth`, `SystemConsoleWindow`, `SystemOperationRunLinks`, and existing system directory links
|
|
||||||
- **Why the existing shared path is sufficient or insufficient**: Existing shared paths already solve dashboard placement, read-only system-plane rendering, time-window selection, and system-safe navigation. They are insufficient because none of them currently derives one explainable workspace-health summary from multiple existing truths.
|
|
||||||
- **Allowed deviation and why**: One bounded `CustomerHealth` support namespace is allowed to centralize health derivation. Page-local health arithmetic, widget-local badge vocabularies, or duplicated query seams are not allowed.
|
|
||||||
- **Consistency impact**: Overall health levels must reuse the existing `SystemHealth` vocabulary (`ok`, `warn`, `critical`, `unknown`), and the first-slice dimension labels plus time-basis copy must remain consistent between summary widgets and any linked system-safe detail flow.
|
|
||||||
- **Review focus**: Reviewers must verify that the slice stays derived-only, does not add a hidden score table, does not invent a second badge taxonomy, does not create platform-to-admin deep links, and does not broaden into customer-success or billing workflow scope.
|
|
||||||
|
|
||||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
|
||||||
|
|
||||||
- **Touches OperationRun start/completion/link UX?**: no
|
|
||||||
- **Shared OperationRun UX contract/layer reused**: N/A - the slice reads existing run truth only and may link to existing system run lists.
|
|
||||||
- **Delegated start/completion UX behaviors**: N/A
|
|
||||||
- **Local surface-owned behavior that remains**: N/A
|
|
||||||
- **Queued DB-notification policy**: N/A
|
|
||||||
- **Terminal notification path**: N/A
|
|
||||||
- **Exception required?**: none
|
|
||||||
|
|
||||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
|
||||||
|
|
||||||
- **Shared provider/platform boundary touched?**: yes
|
|
||||||
- **Boundary classification**: mixed
|
|
||||||
- **Seams affected**: provider verification and consent statuses as one health-dimension input, review-pack status labels, telemetry family activity, and system-safe health copy
|
|
||||||
- **Neutral platform terms preserved or introduced**: customer health, workspace health, health dimension, attention needed, engagement freshness, review readiness, operational stability
|
|
||||||
- **Provider-specific semantics retained and why**: Microsoft-specific provider consent and verification outcomes remain inside the provider-health dimension because they are existing current-release truth owned by provider-connection workflows.
|
|
||||||
- **Why this does not deepen provider coupling accidentally**: The top-level summary stays platform-owned and explainable. Provider-specific codes and statuses are consumed only as one input dimension and are not promoted into a new platform taxonomy.
|
|
||||||
- **Follow-up path**: Customer Lifecycle Communication and later portfolio workflows may reuse the summary, but they remain separate specs.
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
|
||||||
|
|
||||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| System dashboard customer-health KPI widget | yes | Native Filament + shared stats widget | dashboard signals/cards | page, widget, window query | no | Read-only KPI addition on existing `/system` dashboard |
|
|
||||||
| System dashboard attention-needed workspace widget | yes | Native system widget family with custom Blade view | dashboard signals/cards, read-only attention list, navigation links | page, widget, detail-link state | no | Compact list of unhealthy workspaces only; no new page or workbench |
|
|
||||||
| System tenant and workspace residual detail pages customer-health decision card | yes | Existing custom system detail pages | linked follow-up context, decision-first explanation | page, window query | no | Read-only summary card above existing diagnostics; no new page or mutation |
|
|
||||||
|
|
||||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| System dashboard customer-health KPI widget | Secondary Context Surface | Decide whether overall platform attention is rising and whether to inspect unhealthy workspaces now | Count of healthy, warning, critical, and unknown workspaces for the selected window | Existing system tenant directory, operations list, and source surfaces remain evidence | Secondary because it frames attention, not the underlying remediation workflow | Fits the founder or platform-operator control-tower loop | Replaces manual reconstruction across onboarding, telemetry, runs, and findings surfaces |
|
|
||||||
| System dashboard attention-needed workspace widget | Secondary Context Surface | Decide which workspace to inspect next and which source truth is driving the problem | Workspace name, overall level, dominant dimensions, and one platform-safe next link | Existing system-safe detail surfaces and linked source contexts | Secondary because the actual action still happens on existing system detail and run surfaces | Keeps the dashboard as a triage layer, not a second operations workbench | Surfaces the next workspace to inspect without requiring freeform database or log inspection |
|
|
||||||
| System tenant and workspace residual detail pages customer-health decision card | Primary Decision Context | Decide why this workspace is `critical`, `warn`, or `unknown` before reading lower-level diagnostics | Overall level, repeated top drivers, operator impact, and recommended next action | Existing connectivity, permissions, tenant, and recent-run sections remain the lower-level evidence | Primary because the card turns the residual detail page into an explain-first follow-up instead of a diagnostic puzzle | Keeps `/system` drilldowns decision-first while preserving the existing read-mostly detail shape | Removes the need to infer the health reason from several independent sections |
|
|
||||||
|
|
||||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| System dashboard customer-health KPI widget | Dashboard / Overview / KPI widget | Read-only operational summary | Decide whether there is platform-wide attention pressure | In-page stat cards | forbidden | none | none | `/system` | `/system` | Existing dashboard time window and health-level counts | Customer health / Workspace health | Current health-level distribution for visible workspaces | none |
|
|
||||||
| System dashboard attention-needed workspace widget | Dashboard / Overview / compact list widget | Read-only attention registry | Open the most relevant existing system-safe detail surface for one unhealthy workspace | Named link per workspace row | forbidden | One explicit platform-safe next link per row | none | `/system` | existing linked system detail surfaces only | Workspace label, dominant dimensions, and selected time window | Attention-needed workspaces / Workspace | Which workspace needs review next and why | Compact dashboard widget justified because the list is triage-only, not a new registry page |
|
|
||||||
|
|
||||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| System dashboard customer-health KPI widget | Platform operator | Decide whether customer or workspace health is deteriorating overall | Dashboard summary | How many workspaces are healthy, unknown, warning, or critical right now? | Level counts, selected time window, and clear empty or unknown handling | Raw contributing rows, provider-specific details, and per-tenant evidence stay out of the widget | overall health level only | none | existing dashboard time-window selection only | none |
|
|
||||||
| System dashboard attention-needed workspace widget | Platform operator | Decide which workspace to inspect next and what kind of issue is driving it | Compact attention list | Which workspace needs attention first, and what is the dominant reason? | Workspace label, overall level, top one or two non-ok dimensions, and one system-safe next link | Full source record sets, provider codes, detailed finding counts, and review-pack internals remain in linked surfaces | overall level plus dominant health dimensions | none | open linked system-safe detail surface | none |
|
|
||||||
| System tenant and workspace residual detail pages customer-health decision card | Platform operator | Decide what to review first on the current detail page | Read-only follow-up summary | Why is this workspace unhealthy or unknown, and what should I inspect next? | Overall health, repeated top drivers, operator impact, recommended next action, and preserved selected-window context for windowed dimensions | Lower-level connectivity, permission, tenant, and recent-run evidence remains in the existing sections below | overall level plus dominant health dimensions | none | open existing linked follow-up surfaces only when already present on the page | none |
|
|
||||||
|
|
||||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
|
||||||
|
|
||||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| System dashboard page | `App\Filament\System\Pages\Dashboard` | Existing `Time window` header action only; no new header actions | n/a | n/a | none | n/a | n/a | n/a | no | Action Surface Contract remains satisfied because the feature adds read-only widgets only and does not alter the page-level mutation model |
|
|
||||||
| Attention-needed workspace widget | `App\Filament\System\Widgets\CustomerHealthTopWorkspaces` | none | One named platform-safe next link per row; row click remains forbidden | Max one visible next link, chosen as `Review health details` or `Open runs` based on the dominant reason | none | One explanatory empty state with no CTA when no workspace needs attention | n/a | n/a | no | Every row must still expose one platform-safe next link. If no more specific route fits, the row falls back to the existing system tenant-detail context rather than inventing a new surface or showing no link |
|
|
||||||
|
|
||||||
**List-surface standard reference:** The attention-needed workspace widget is a compact list surface and MUST be reviewed against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/docs/product/standards/list-surface-review-checklist.md` before implementation sign-off. Accepted v1 exceptions are limited to compact dashboard-widget constraints: no persistence trio, no bulk actions, no row click, and no empty-state CTA because this surface is triage-only rather than a standalone registry page.
|
|
||||||
|
|
||||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
|
||||||
|
|
||||||
- **New source of truth?**: no
|
|
||||||
- **New persisted entity/table/artifact?**: no
|
|
||||||
- **New abstraction?**: yes
|
|
||||||
- **New enum/state/reason family?**: no - the slice reuses existing `SystemHealth` levels
|
|
||||||
- **New cross-domain UI framework/taxonomy?**: no
|
|
||||||
- **Current operator problem**: the platform has no single explainable workspace-health summary even though the underlying truth already exists in onboarding, provider, operations, findings, review packs, and telemetry.
|
|
||||||
- **Existing structure is insufficient because**: the existing structures each explain one domain only. None can honestly answer which workspace is unhealthy overall without duplicating cross-domain query logic or forcing manual reconstruction.
|
|
||||||
- **Narrowest correct implementation**: add one bounded derived-query path and one small fixed dimension catalog, then surface it on the existing system dashboard only.
|
|
||||||
- **Ownership cost**: one support namespace, two dashboard widgets, fixed dimension rules, and focused unit plus feature coverage.
|
|
||||||
- **Alternative intentionally rejected**: a persisted score table, a customer-success page, a background recomputation pipeline, or an opaque weighted scoring engine.
|
|
||||||
- **Release truth**: current-release truth
|
|
||||||
|
|
||||||
### Compatibility posture
|
|
||||||
|
|
||||||
This feature assumes a pre-production environment.
|
|
||||||
|
|
||||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
|
||||||
|
|
||||||
Canonical replacement is preferred over preservation.
|
|
||||||
|
|
||||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
|
||||||
|
|
||||||
- **Test purpose / classification**: Unit, Feature
|
|
||||||
- **Validation lane(s)**: fast-feedback, confidence
|
|
||||||
- **Why this classification and these lanes are sufficient**: Unit tests can prove health-level derivation, dimension precedence, unknown handling, and signal inclusion rules. Feature tests can prove dashboard rendering, platform-plane authorization, and system-safe link behavior without browser automation.
|
|
||||||
- **New or expanded test families**: One focused `CustomerHealth` unit family plus a small set of `/system` feature tests for summary widgets, explainability, and authorization.
|
|
||||||
- **Fixture / helper cost impact**: Moderate. Reuse existing workspaces, tenants, provider connections, onboarding sessions, product-usage events, operation runs, findings, and review packs. Avoid new browser helpers, seeded dashboards, or heavy system defaults.
|
|
||||||
- **Heavy-family visibility / justification**: none
|
|
||||||
- **Special surface test profile**: standard-native-filament
|
|
||||||
- **Standard-native relief or required special coverage**: standard native Filament feature coverage is sufficient because the slice adds read-only widgets only.
|
|
||||||
- **Reviewer handoff**: Reviewers must confirm that the health summary is derived-only, that `unknown` and stale data are not silently treated as healthy, that system-safe links remain on the platform plane, that no new persistence appears, and that the selected window affects only the intended windowed dimensions.
|
|
||||||
- **Budget / baseline / trend impact**: Low-to-moderate increase in narrow unit plus feature coverage only.
|
|
||||||
- **Escalation needed**: none
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Test-governance outcome**: keep
|
|
||||||
- **Planned validation commands**:
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/CustomerHealthDimensionCatalogTest.php tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/CustomerHealth/CustomerHealthDashboardWidgetsTest.php tests/Feature/System/CustomerHealth/CustomerHealthExplainabilityTest.php tests/Feature/System/CustomerHealth/CustomerHealthAuthorizationTest.php`
|
|
||||||
|
|
||||||
## Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The system MUST derive one workspace-health summary from exactly six first-slice dimensions: onboarding readiness, provider connection health, operational stability, governance pressure, review-pack readiness, and engagement freshness.
|
|
||||||
- **FR-002**: The system MUST compute the overall workspace health level from existing `SystemHealth` semantics with explicit precedence: `critical` outranks `warn`, `warn` outranks `unknown`, and `unknown` outranks `ok`.
|
|
||||||
- **FR-003**: The system MUST keep the first slice derived-only. No persisted score table, asynchronous recomputation store, or secondary health ledger may be introduced.
|
|
||||||
- **FR-004**: The existing `/system` dashboard MUST show aggregate health counts and an attention-needed workspace list using the existing dashboard window selector for windowed dimensions.
|
|
||||||
- **FR-005**: The system MUST keep platform-safe navigation boundaries. Health widgets may link only to existing platform-plane system surfaces, never directly to tenant-admin `/admin` routes.
|
|
||||||
- **FR-006**: The system MUST make stale or missing source truth explicit. Missing or stale inputs must not silently produce `ok`.
|
|
||||||
- **FR-007**: Review-pack readiness in the first slice MUST stay narrow: it may reflect recent review-pack requests and the latest relevant pack status, but it must not become a quarterly review-obligation engine.
|
|
||||||
- **FR-008**: The slice MUST not introduce any new customer-facing communication, entitlement, billing, or AI workflow.
|
|
||||||
|
|
||||||
## Non-Functional Requirements
|
|
||||||
|
|
||||||
- **NFR-001**: The summary query must stay bounded and reusable from the existing system dashboard without new background jobs or remote calls.
|
|
||||||
- **NFR-002**: Widget copy must remain calm, explainable, and dashboard-first rather than CRM-like, sales-like, or predictive.
|
|
||||||
- **NFR-003**: Health-level rendering must reuse existing badge or status semantics instead of inventing a second color or severity language.
|
|
||||||
|
|
||||||
## UX Requirements
|
|
||||||
|
|
||||||
- **UX-001**: The dashboard must remain triage-first. Summary counts and dominant reasons are visible by default; raw counts and low-level diagnostics stay behind existing linked surfaces.
|
|
||||||
- **UX-002**: The slice must not become a new workbench page. `/system` remains the only first-slice entry point, and any health follow-up must stay inside existing residual detail pages rather than creating a new health detail page.
|
|
||||||
- **UX-003**: The widget and the linked residual detail decision card must distinguish point-in-time dimensions from windowed dimensions in their copy or presentation so operators do not assume one false time basis.
|
|
||||||
|
|
||||||
## RBAC / Security Requirements
|
|
||||||
|
|
||||||
- **SEC-001**: Only existing platform users who can already access `/system` may see customer-health widgets.
|
|
||||||
- **SEC-002**: No tenant/admin-plane, customer-facing, or raw-row viewer may be introduced in this slice.
|
|
||||||
- **SEC-003**: System-safe deep links must remain guarded by existing platform-plane route policies and must not reveal tenant-admin paths or scoped data to the wrong plane.
|
|
||||||
|
|
||||||
## Auditability / Observability Requirements
|
|
||||||
|
|
||||||
- **OBS-001**: The slice must reuse existing product truths and must not duplicate audit entries or OperationRun records for derived reads.
|
|
||||||
- **OBS-002**: The source dimension set and level precedence must be testable so future changes cannot silently redefine health semantics.
|
|
||||||
|
|
||||||
## Data / Truth-Source Requirements
|
|
||||||
|
|
||||||
- **DATA-001**: Onboarding readiness must reuse existing onboarding-session and readiness truth rather than a copied health field.
|
|
||||||
- **DATA-002**: Provider connection health must reuse provider-owned verification and consent truth rather than a new platform-owned provider score.
|
|
||||||
- **DATA-003**: Operational stability must reuse existing failed and stuck `OperationRun` truth within the selected `SystemConsoleWindow`.
|
|
||||||
- **DATA-004**: Governance pressure must reuse existing findings and risk-acceptance truth.
|
|
||||||
- **DATA-005**: Review-pack readiness must reuse existing review-pack truth plus recent request context where needed.
|
|
||||||
- **DATA-006**: Engagement freshness must reuse existing `product_usage_events` truth rather than page views or ad-hoc counters.
|
|
||||||
- **DATA-007**: Archived workspaces must be excluded from active portfolio counts, and archived tenants must not contribute source truth into active workspace-health derivation.
|
|
||||||
|
|
||||||
## Review-Pack Readiness Rule
|
|
||||||
|
|
||||||
`Recent` means the currently selected dashboard window. Request context is derived from existing review-pack request telemetry when available, falling back to a `ReviewPack` created for the same workspace inside that same window.
|
|
||||||
|
|
||||||
| Condition | Review-pack readiness level | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| No request context and no relevant pack activity in the selected window | `unknown` | The first slice does not infer quarterly review obligations from silence |
|
|
||||||
| Request context exists in the selected window and no relevant pack is usable yet | `warn` | Covers queued, running, or not-yet-materialized pack generation |
|
|
||||||
| Latest relevant pack in the selected window is ready and not expired | `ok` | A usable recent pack exists |
|
|
||||||
| Latest relevant pack in the selected window is failed or expired | `critical` | Recent review-pack work ended unusably and needs attention |
|
|
||||||
|
|
||||||
## Attention Ordering Rule
|
|
||||||
|
|
||||||
The attention-needed workspace list is deterministic in v1:
|
|
||||||
|
|
||||||
1. Higher overall health severity sorts first: `critical` before `warn` before `unknown`
|
|
||||||
2. Within the same overall level, workspaces with more non-`ok` dimensions sort first
|
|
||||||
3. Remaining ties sort by workspace name ascending, then workspace id ascending
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - See Portfolio Health At A Glance (Priority: P1)
|
|
||||||
|
|
||||||
As a platform operator, I need the existing `/system` dashboard to summarize customer or workspace health so I can tell quickly whether attention pressure is rising.
|
|
||||||
|
|
||||||
**Why this priority**: Without an aggregate signal, operators still need to inspect several unrelated surfaces before they even know whether a health problem exists.
|
|
||||||
|
|
||||||
**Independent Test**: Seed workspaces with different combinations of healthy, warning, critical, and unknown source truth and verify that `/system` renders the correct health-level counts, explicit unknown handling, and a visible cue for windowed versus point-in-time dimensions.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** multiple visible workspaces with mixed source truth, **When** an authorized platform user opens `/system`, **Then** the dashboard shows aggregate counts for `ok`, `warn`, `critical`, and `unknown` workspaces using the first-slice health rules.
|
|
||||||
2. **Given** a workspace has only stale or missing source truth for one or more dimensions, **When** the dashboard renders, **Then** that workspace is not shown as healthy by default.
|
|
||||||
3. **Given** the summary mixes operational and point-in-time signals, **When** the dashboard renders, **Then** the widget shows a visible cue that operations and engagement honor the selected window while onboarding, provider, governance, and review-readiness remain state-based.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Review Attention-Needed Workspaces With Explainable Reasons (Priority: P1)
|
|
||||||
|
|
||||||
As a platform operator, I need a compact list of the workspaces that need attention most so I know where to inspect next and why.
|
|
||||||
|
|
||||||
**Why this priority**: Aggregate counts alone do not reduce support load unless operators can immediately identify which workspace needs review and what category of issue is driving that state.
|
|
||||||
|
|
||||||
**Independent Test**: Seed multiple unhealthy workspaces and confirm the dashboard lists them with overall health level, dominant dimensions, and one platform-safe next link per row.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** multiple warning or critical workspaces, **When** the dashboard renders, **Then** the attention-needed widget lists the worst workspaces first with overall level and dominant health dimensions.
|
|
||||||
2. **Given** a workspace row offers a next action, **When** the user activates that action, **Then** the link stays on an existing platform-plane system surface and does not deep-link into `/admin` tenant routes.
|
|
||||||
3. **Given** the operator follows a health-detail link from the dashboard, **When** the linked system tenant or workspace detail page renders, **Then** a decision-first customer-health card appears above the existing diagnostics and repeats the same dominant health drivers together with overall level, impact, and recommended next action.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Keep Health Honest And Narrow (Priority: P2)
|
|
||||||
|
|
||||||
As the product owner, I need the health summary to stay explainable and bounded so it does not become an opaque score, a hidden persistence layer, or a substitute for existing source truth.
|
|
||||||
|
|
||||||
**Why this priority**: A misleading or overbuilt score would add maintenance and trust debt immediately.
|
|
||||||
|
|
||||||
**Independent Test**: Inspect the derived summary rules and verify that unknown or mixed-time-basis inputs remain explicit, no new persistence appears, and no customer-facing workflow is introduced.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the first-slice dimension rules are evaluated, **When** one dimension is `critical` and another is `unknown`, **Then** the overall workspace level resolves deterministically and the dominant reasons remain explainable.
|
|
||||||
2. **Given** recent review-pack generation was requested but no usable pack is yet available, **When** the review-readiness dimension is evaluated, **Then** the workspace is not falsely shown as healthy.
|
|
||||||
3. **Given** a user without existing `/system` access attempts to read customer health, **When** they hit the surface, **Then** access stays denied through the existing platform-plane gate.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- Archived workspaces or tenants must not inflate active health counts.
|
|
||||||
- A workspace can have healthy current provider state but no recent engagement telemetry; the slice must make the time basis explicit instead of collapsing that into a misleading single timestamp.
|
|
||||||
- A recent review-pack request can exist before a ready pack exists; that gap must not render as healthy.
|
|
||||||
- A workspace with no onboarding-linked tenant yet must remain `unknown` or explicitly non-healthy rather than silently `ok`.
|
|
||||||
- Mixed workspace truth across several tenants must still produce one explainable workspace-level result without exposing raw tenant-owned rows on the dashboard.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- The first slice derives workspace health from six fixed dimensions only and keeps all health truth derived from existing records.
|
|
||||||
- The existing `/system` dashboard exposes both aggregate health counts and an attention-needed workspace list.
|
|
||||||
- Operators can see dominant reasons for unhealthy workspaces without opening a second system page first.
|
|
||||||
- Unknown or stale source truth is explicit and never silently treated as healthy.
|
|
||||||
- The slice introduces no new persisted health entity, no customer-facing view, and no new platform-to-admin deep link.
|
|
||||||
|
|
||||||
## Out Of Scope
|
|
||||||
|
|
||||||
- Customer-facing health portals
|
|
||||||
- Billing or entitlement-aware health dimensions
|
|
||||||
- Automated customer lifecycle messaging
|
|
||||||
- Predictive scoring, churn scoring, or AI-generated recommendations
|
|
||||||
- A dedicated portfolio-health page outside the existing `/system` dashboard
|
|
||||||
- A new persisted workspace-health model or scheduled recomputation job
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
- A platform operator can identify whether any workspace needs attention, and which workspace to inspect first, within one `/system` dashboard visit.
|
|
||||||
- The first slice remains explainable enough that each visible non-healthy state can be tied back to one or two named dimensions.
|
|
||||||
- The package stays implementation-ready without introducing unresolved product decisions around billing, CRM, AI, or customer-facing UX.
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- Product Usage & Adoption Telemetry is available or implemented before this slice is delivered.
|
|
||||||
- Existing system dashboard access rules remain the correct audience gate for the first slice.
|
|
||||||
- Existing provider-connection, finding, review-pack, and run surfaces remain the source-of-truth owners for detailed inspection.
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- Too many first-slice dimensions would make the dashboard noisy; the dimension inventory is fixed at six for v1.
|
|
||||||
- Mixed point-in-time and windowed signals can confuse operators if the UI does not label them carefully.
|
|
||||||
- Review-pack readiness could drift into a broader compliance-workflow interpretation if it is not kept narrow.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
- None blocking the first slice. The v1 contract guarantees one platform-safe next link per workspace row by falling back to the existing system tenant-detail context when a more specific platform-plane route is not appropriate.
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
**Constitution alignment (required):** The slice adds no Microsoft Graph calls, no mutation flow, and no new queued or scheduled work. It derives read-only health summaries from existing onboarding, provider, telemetry, findings, review-pack, and `OperationRun` truth only.
|
|
||||||
|
|
||||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces no new persistence and reuses existing health-level semantics. The only new structure is one bounded derived-query path plus a fixed dimension catalog because current-release operator workflow now needs a single explainable workspace-health summary.
|
|
||||||
|
|
||||||
**Constitution alignment (XCUT-001):** This slice explicitly extends the shared system dashboard widget family, system-safe links, and shared `SystemHealth` presentation instead of inventing a second health dashboard language.
|
|
||||||
|
|
||||||
**Constitution alignment (PROV-001):** Provider-specific verification and consent semantics remain provider-owned inputs only; the top-level customer-health summary stays platform-owned and neutral.
|
|
||||||
|
|
||||||
**Constitution alignment (TEST-GOV-001):** Proof stays in Unit + Feature lanes only. No heavy-governance or browser family is justified.
|
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX):** Existing `OperationRun` start, completion, notification, and link semantics remain unchanged. The slice reads existing run truth and may link to existing system operations surfaces only.
|
|
||||||
|
|
||||||
**Constitution alignment (RBAC-UX):** Reads remain platform-plane only through existing `/system` access checks. No tenant/admin or customer-facing health viewer is introduced.
|
|
||||||
|
|
||||||
**Constitution alignment (BADGE-001):** The feature reuses existing `BadgeDomain::SystemHealth` rendering and does not add a new badge taxonomy.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-FIL-001):** The only operator-facing changes are native Filament system widgets on the existing dashboard. No published views, panel provider changes, or custom asset pipeline changes are introduced.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** Widget labels and dominant health reasons must remain operator-first and platform-neutral, such as `Customer health`, `Attention-needed workspaces`, `Provider health`, `Operational stability`, and `Engagement freshness`.
|
|
||||||
|
|
||||||
**Filament v5 / Livewire v4 compliance:** The slice remains fully inside the current Filament v5 + Livewire v4 stack.
|
|
||||||
|
|
||||||
**Provider registration location:** No provider registration changes are introduced; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
|
|
||||||
|
|
||||||
**Global search rule:** No new resource or global-search participation is introduced.
|
|
||||||
|
|
||||||
**Destructive actions:** No destructive actions are added in this slice.
|
|
||||||
|
|
||||||
**Asset strategy:** No new global or on-demand assets are added. Deployment behavior for `filament:assets` remains unchanged.
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
---
|
|
||||||
|
|
||||||
description: "Task list for Customer Health Score"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Tasks: Customer Health Score
|
|
||||||
|
|
||||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/245-customer-health-score/`
|
|
||||||
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/245-customer-health-score/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/245-customer-health-score/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/245-customer-health-score/checklists/requirements.md` (required)
|
|
||||||
|
|
||||||
**Tests (TEST-GOV-001)**: REQUIRED (Pest) for all runtime behavior changes in this slice. Keep proof in Unit + Feature lanes only.
|
|
||||||
**Operations**: This slice must not change `OperationRun` start, completion, notification, or link UX.
|
|
||||||
**RBAC**: Existing `/system` dashboard access remains authoritative. No tenant/admin-plane or customer-facing health viewer is introduced.
|
|
||||||
**Organization**: Tasks are grouped by user story so aggregate health counts, the attention-needed workspace list, and edge-case hardening remain independently deliverable. Core unknown-handling lives in the foundational query path.
|
|
||||||
|
|
||||||
## Phase 1: Setup (Shared Infrastructure)
|
|
||||||
|
|
||||||
**Purpose**: Prepare the bounded support namespace and narrow test surfaces for the first slice.
|
|
||||||
|
|
||||||
- [x] T001 Create the feature-local support namespace and test directories under `apps/platform/app/Support/CustomerHealth/`, `apps/platform/tests/Unit/Support/CustomerHealth/`, and `apps/platform/tests/Feature/System/CustomerHealth/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Prerequisites)
|
|
||||||
|
|
||||||
**Purpose**: Add the single shared dimension catalog and derived summary query before any dashboard UI adoption.
|
|
||||||
|
|
||||||
**Checkpoint**: One bounded, derived-only customer-health path exists before the dashboard starts rendering it, including the base unknown-handling and review-pack-readiness rules.
|
|
||||||
|
|
||||||
- [x] T002 Create the fixed first-slice dimension catalog in `apps/platform/app/Support/CustomerHealth/CustomerHealthDimensionCatalog.php`, reusing existing `SystemHealth` level semantics and locking the first slice to onboarding readiness, provider connection health, operational stability, governance pressure, review-pack readiness, and engagement freshness
|
|
||||||
- [x] T003 Create the derived workspace summary query in `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php` that reads existing onboarding, provider connection, telemetry, `OperationRun`, findings, and review-pack truth without introducing a persisted score model, and bakes in the base unknown-handling plus selected-window review-pack-readiness rules
|
|
||||||
- [x] T004 [P] Add unit coverage for dimension labels, level precedence, unknown handling, the selected-window review-pack-readiness rule table, and windowed versus point-in-time signal rules in `apps/platform/tests/Unit/Support/CustomerHealth/CustomerHealthDimensionCatalogTest.php` and `apps/platform/tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php`
|
|
||||||
- [x] T005 Run the foundational unit suite with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/CustomerHealthDimensionCatalogTest.php tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: User Story 1 - See Portfolio Health At A Glance (Priority: P1) 🎯 MVP
|
|
||||||
|
|
||||||
**Goal**: Show aggregate workspace-health counts on the existing `/system` dashboard.
|
|
||||||
|
|
||||||
**Independent Test**: Seed mixed workspace truth and verify the dashboard renders aggregate `ok`, `warn`, `critical`, and `unknown` counts with explicit unknown handling.
|
|
||||||
|
|
||||||
### Tests for User Story 1
|
|
||||||
|
|
||||||
- [x] T006 [P] [US1] Add dashboard feature coverage for aggregate health counts, unknown handling, selected-window behavior, and a visible time-basis cue in `apps/platform/tests/Feature/System/CustomerHealth/CustomerHealthDashboardWidgetsTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 1
|
|
||||||
|
|
||||||
- [x] T007 [US1] Create the summary stats widget in `apps/platform/app/Filament/System/Widgets/CustomerHealthKpis.php` using the shared `StatsOverviewWidget` pattern and `WorkspaceHealthSummaryQuery`, including visible copy that distinguishes selected-window dimensions from point-in-time dimensions
|
|
||||||
- [x] T008 [US1] Register the new summary widget on `apps/platform/app/Filament/System/Pages/Dashboard.php` without changing the existing system dashboard access gate or header actions
|
|
||||||
- [x] T009 [US1] Run the first-slice dashboard summary proof with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/CustomerHealth/CustomerHealthDashboardWidgetsTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: User Story 2 - Review Attention-Needed Workspaces With Explainable Reasons (Priority: P1)
|
|
||||||
|
|
||||||
**Goal**: List the worst workspaces first with dominant health dimensions and one platform-safe next link.
|
|
||||||
|
|
||||||
**Independent Test**: Seed several unhealthy workspaces and verify the dashboard shows them in priority order with dominant reasons and platform-safe links only.
|
|
||||||
|
|
||||||
### Tests for User Story 2
|
|
||||||
|
|
||||||
- [x] T010 [P] [US2] Add feature coverage for deterministic workspace ordering, dominant-dimension rendering, operator-first default disclosure, absence of raw/support detail on the default dashboard surface, absence of duplicate visible decision summaries across the two widgets, and exactly one platform-safe next link per row with the tenant-detail fallback path in `apps/platform/tests/Feature/System/CustomerHealth/CustomerHealthExplainabilityTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 2
|
|
||||||
|
|
||||||
- [x] T011 [US2] Create the compact attention widget in `apps/platform/app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php`
|
|
||||||
- [x] T012 [US2] Add the companion Blade view in `apps/platform/resources/views/filament/system/widgets/customer-health-top-workspaces.blade.php`, keeping the surface read-only and triage-first
|
|
||||||
- [x] T013 [US2] Use existing platform-plane link helpers inside `apps/platform/app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php` so each row exposes exactly one next link on system directory or system operations surfaces, with fallback to system tenant detail when no more specific platform-plane route is appropriate
|
|
||||||
- [x] T014 [US2] Register the attention-needed widget on `apps/platform/app/Filament/System/Pages/Dashboard.php` without turning `/system` into a second workbench page
|
|
||||||
- [x] T015 [US2] Run the explainability and link proof with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/CustomerHealth/CustomerHealthExplainabilityTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: User Story 3 - Keep Health Honest And Narrow (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: Harden archived-workspace and archived-tenant handling, authorization, and dominant-reason edge cases after the foundational unknown-handling rules are already in place.
|
|
||||||
|
|
||||||
**Independent Test**: Verify that archived workspaces stay out of active attention counts, archived tenants do not drive active workspace health, review-pack edge cases continue to follow the selected-window rule, and unauthorized users cannot read the widgets.
|
|
||||||
|
|
||||||
### Tests for User Story 3
|
|
||||||
|
|
||||||
- [x] T016 [P] [US3] Add feature coverage for `/system` authorization boundaries in `apps/platform/tests/Feature/System/CustomerHealth/CustomerHealthAuthorizationTest.php`
|
|
||||||
- [x] T017 [P] [US3] Extend `apps/platform/tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php` with archived-workspace, archived-tenant, dominant-reason ordering, and recent-review-pack-request edge cases that harden the existing foundational rules
|
|
||||||
|
|
||||||
### Implementation for User Story 3
|
|
||||||
|
|
||||||
- [x] T018 [US3] Harden `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php` for archived-workspace and archived-tenant exclusions, dominant-reason ordering, and review-pack edge cases without moving the core unknown-handling rules out of the foundation
|
|
||||||
- [x] T019 [US3] Keep overall level rendering on existing `BadgeDomain::SystemHealth` semantics inside `apps/platform/app/Filament/System/Widgets/CustomerHealthKpis.php` and `apps/platform/app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php` rather than introducing a new score language
|
|
||||||
- [x] T020 [US3] Run the narrow safety and authorization proof with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Feature/System/CustomerHealth/CustomerHealthAuthorizationTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Polish & Cross-Cutting Concerns
|
|
||||||
|
|
||||||
**Purpose**: Lock down vocabulary, formatting, and the minimal validation suite before implementation close-out.
|
|
||||||
|
|
||||||
- [x] T021 [P] Confirm that dashboard labels, dominant reason copy, health levels, the visible time-basis cue, and the no-duplicate-visible-summary rule stay aligned across `apps/platform/app/Support/CustomerHealth/CustomerHealthDimensionCatalog.php`, the dashboard widgets, and any linked platform-safe surfaces
|
|
||||||
- [x] T022 Run formatting on touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
|
||||||
- [x] T023 Run the full narrow validation suite with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth tests/Feature/System/CustomerHealth/CustomerHealthDashboardWidgetsTest.php tests/Feature/System/CustomerHealth/CustomerHealthExplainabilityTest.php tests/Feature/System/CustomerHealth/CustomerHealthAuthorizationTest.php`
|
|
||||||
- [x] T024 Review `apps/platform/app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php` and `apps/platform/resources/views/filament/system/widgets/customer-health-top-workspaces.blade.php` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/docs/product/standards/list-surface-review-checklist.md`, recording the accepted compact-widget exceptions before sign-off
|
|
||||||
|
|
||||||
Accepted compact-widget exceptions for sign-off: no persistence trio, no bulk actions, no row click, and no empty-state CTA because this is a read-only dashboard triage widget rather than a standalone Filament table surface.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7: Detail Follow-Up Decision Context
|
|
||||||
|
|
||||||
**Purpose**: Keep the dashboard-to-detail drilldown explainable by repeating the health decision above existing residual diagnostics.
|
|
||||||
|
|
||||||
- [x] T025 [US2] Preserve the selected dashboard time window on system health-detail drilldown links in `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php` so the linked residual detail pages can explain the same dominant drivers as the dashboard
|
|
||||||
- [x] T026 [US2] Rename the tenant/workspace drilldown affordance in `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php` from an opening verb to the decision-first `Review health details` copy while keeping `Open runs` unchanged for operational follow-up
|
|
||||||
- [x] T027 [US2] Add a read-only customer-health decision card above existing diagnostics on `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php`, `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php`, and their directory Blade views by reusing `WorkspaceHealthSummaryQuery`
|
|
||||||
- [x] T028 [P] [US2] Add focused feature coverage for the new residual detail follow-up card in `apps/platform/tests/Feature/System/CustomerHealth/CustomerHealthDetailDecisionTest.php`
|
|
||||||
- [x] T029 [US2] Run the focused drilldown validation proof with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Feature/System/CustomerHealth/CustomerHealthExplainabilityTest.php tests/Feature/System/CustomerHealth/CustomerHealthDetailDecisionTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies & Execution Order
|
|
||||||
|
|
||||||
### Recommended Execution Order
|
|
||||||
|
|
||||||
```text
|
|
||||||
Phase 1 (Setup)
|
|
||||||
↓
|
|
||||||
Phase 2 (Dimension catalog + derived summary query)
|
|
||||||
↙ ↘
|
|
||||||
US1 (aggregate health counts) US2 (attention-needed workspace list)
|
|
||||||
↓
|
|
||||||
US3 (honest unknown handling + authorization hardening)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Parallel Opportunities
|
|
||||||
|
|
||||||
- The foundational unit tests can be authored in parallel once the fixed first-slice dimension inventory is agreed.
|
|
||||||
- The aggregate summary widget and attention-needed widget feature tests can be written in parallel after the derived summary shape is stable.
|
|
||||||
- Authorization coverage can proceed in parallel with final unknown-handling hardening because it exercises existing `/system` gates.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Governance Checklist
|
|
||||||
|
|
||||||
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
|
||||||
- [x] New or changed tests stay in the smallest honest family, and no heavy-governance or browser family is introduced accidentally.
|
|
||||||
- [x] Shared helpers and fixture setup remain cheap by default.
|
|
||||||
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
|
|
||||||
- [x] The adopted surfaces explicitly use `standard-native-filament` relief.
|
|
||||||
- [x] No material budget or baseline escalation is introduced.
|
|
||||||
|
|
||||||
**Test-governance outcome (TEST-GOV-001)**: keep
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
# Specification Quality Checklist: In-App Support Request with Context
|
|
||||||
|
|
||||||
**Purpose**: Validate specification completeness and implementation readiness before the feature moves into the implementation loop
|
|
||||||
**Created**: 2026-04-27
|
|
||||||
**Feature**: [spec.md](../spec.md)
|
|
||||||
|
|
||||||
## Content Quality
|
|
||||||
|
|
||||||
- [x] Business value and operator outcomes stay explicit
|
|
||||||
- [x] The first slice is bounded to two existing support-aware entry surfaces plus one internal support reference only
|
|
||||||
- [x] Runtime-governance sections are present for an implementation-ready package
|
|
||||||
- [x] All mandatory sections are completed
|
|
||||||
|
|
||||||
## Requirement Completeness
|
|
||||||
|
|
||||||
- [x] No `[NEEDS CLARIFICATION]` markers remain
|
|
||||||
- [x] Requirements are testable and unambiguous
|
|
||||||
- [x] Acceptance scenarios are defined for the primary user journeys
|
|
||||||
- [x] Edge cases are identified, including reduced-attachment mode and missing related records
|
|
||||||
- [x] Scope is clearly bounded away from external ticketing, support inboxes, request lifecycle workflow, and customer-facing portals
|
|
||||||
- [x] Dependencies and assumptions are identified
|
|
||||||
|
|
||||||
## Feature Readiness
|
|
||||||
|
|
||||||
- [x] The first slice is small enough for a bounded implementation loop
|
|
||||||
- [x] The plan identifies concrete repo surfaces likely to change
|
|
||||||
- [x] The tasks are ordered, testable, and grouped by user story
|
|
||||||
- [x] Foundational work includes the persisted truth, capability gate, audit path, and shared context builder before surface wiring
|
|
||||||
- [x] No unresolved product question blocks safe implementation of the first slice
|
|
||||||
|
|
||||||
## Governance Readiness
|
|
||||||
|
|
||||||
- [x] New persisted support-request truth is explicitly justified and bounded
|
|
||||||
- [x] `SupportRequest` ownership is explicit as tenant-owned, with required not-null `workspace_id` and `tenant_id`
|
|
||||||
- [x] Support-request context remains provider-neutral and redaction-aware
|
|
||||||
- [x] Existing `/admin` authorization and tenant-safe 404 versus 403 boundaries remain authoritative
|
|
||||||
- [x] Operator-facing surface changes include the required UI contract sections and action matrix
|
|
||||||
- [x] External ticketing, status workflow, and support inbox surfaces are explicitly deferred
|
|
||||||
- [x] Livewire v4 compliance, unchanged provider registration location, no global-search changes, no destructive-action additions, and no asset-strategy changes are explicit in the package
|
|
||||||
|
|
||||||
## Test Governance Review
|
|
||||||
|
|
||||||
- [x] Lane fit stays in focused unit plus feature validation only and matches the plan
|
|
||||||
- [x] Fixture and helper growth stays local to the `SupportRequests` test namespace
|
|
||||||
- [x] No browser or heavy-governance family is introduced implicitly
|
|
||||||
- [x] Minimal validation commands are explicit in both the plan and the task list
|
|
||||||
- [x] The active feature PR close-out entry remains `Guardrail`
|
|
||||||
|
|
||||||
## Review Outcome
|
|
||||||
|
|
||||||
- [x] Review outcome class: `documentation-required-exception`
|
|
||||||
- [x] Workflow outcome: `document-in-feature`
|
|
||||||
- [x] Final note location: active feature PR close-out entry `Guardrail`
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- This checklist completes the implementation-ready package alongside `spec.md`, `plan.md`, and `tasks.md`.
|
|
||||||
- The active slice stops at structured request creation and internal reference generation. Any later ticket-provider adapter or lifecycle management remains a separate feature.
|
|
||||||
- Guardrail close-out: focused unit and feature validation passed, live browser smoke passed on both approved entry points, the tenant dashboard exemption remained bounded to two support-aware actions, and no provider-registration, global-search, destructive-action, or asset-strategy changes were introduced.
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
# Implementation Plan: In-App Support Request with Context
|
|
||||||
|
|
||||||
**Branch**: `246-support-request-context` | **Date**: 2026-04-27 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/246-support-request-context/spec.md`
|
|
||||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/246-support-request-context/spec.md`
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- Add one bounded `SupportRequest` product truth that captures structured support intake from two existing support-aware surfaces: the tenant dashboard and the canonical operation detail viewer.
|
|
||||||
- Reuse the existing support-diagnostics bundle to attach a redacted, machine-readable context envelope when the creator is allowed to view diagnostics, and fall back to a safe canonical reference set when they are not.
|
|
||||||
- Keep the slice synchronous, Livewire v4-compatible, Filament v5-native, and free of external ticket adapters, `OperationRun` side effects, global-search changes, destructive actions, or asset changes.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
|
||||||
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `SupportDiagnosticBundleBuilder`, `WorkspaceAuditLogger`, `UiEnforcement`, `CapabilityResolver`, and canonical tenant/run support surfaces
|
|
||||||
**Storage**: PostgreSQL tenant-owned `support_requests` table with required not-null `workspace_id` and `tenant_id`, plus immutable redacted context JSON
|
|
||||||
**Testing**: Pest unit + feature tests
|
|
||||||
**Validation Lanes**: fast-feedback, confidence
|
|
||||||
**Target Platform**: Sail-backed Laravel admin panel under `/admin`
|
|
||||||
**Project Type**: web
|
|
||||||
**Performance Goals**: create the support request synchronously inside ordinary admin-request latency with no outbound HTTP and no background jobs
|
|
||||||
**Constraints**: no external ticketing provider, no support inbox or resource, no new system panel, no raw payload persistence, no `OperationRun`, and no asset changes
|
|
||||||
**Scale/Scope**: one migration, one model and factory, one bounded support-request context builder or submission service, one generated reference path, two header actions, and focused unit plus feature proof only
|
|
||||||
|
|
||||||
## First-Slice Request Contract
|
|
||||||
|
|
||||||
The first slice is locked to the following request shape:
|
|
||||||
|
|
||||||
1. **Primary contexts**: `tenant` and `operation_run` only
|
|
||||||
2. **Required submitted fields**: severity, summary, creator identity, workspace, tenant, primary context
|
|
||||||
3. **Severity values**:
|
|
||||||
- `low` = Low
|
|
||||||
- `normal` = Normal (default)
|
|
||||||
- `high` = High
|
|
||||||
- `blocking` = Blocking
|
|
||||||
4. **Optional submitted fields**: reproduction notes, contact name, contact email, attached diagnostic snapshot when allowed
|
|
||||||
5. **Attachment modes**:
|
|
||||||
- `diagnostic_snapshot_attached` when the creator also passes `support_diagnostics.view`
|
|
||||||
- `canonical_context_only` when the creator can create the request but cannot attach support diagnostics
|
|
||||||
6. **Duplicate submissions**: repeated submits are intentionally allowed in v1 and must always create a fresh support request row plus a fresh internal support reference with no hidden merge or dedupe behavior
|
|
||||||
7. **Returned reference**: one immutable internal support reference shown back immediately in success feedback, formatted as `SR-<ULID>`
|
|
||||||
|
|
||||||
Any support inbox, lifecycle state machine, external ticket reference, file upload, or customer-facing support portal is deferred by design.
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Plan
|
|
||||||
|
|
||||||
- **Guardrail scope**: changed surfaces
|
|
||||||
- **Native vs custom classification summary**: native Filament + shared support primitives
|
|
||||||
- **Shared-family relevance**: header actions, support capture, support diagnostics, audit feedback
|
|
||||||
- **State layers in scope**: page, detail, action form
|
|
||||||
- **Audience modes in scope**: operator-MSP, support-platform
|
|
||||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first form, diagnostics-second, support-raw omitted from persistence
|
|
||||||
- **Raw/support gating plan**: capability-gated attachment, raw payloads never persisted
|
|
||||||
- **One-primary-action / duplicate-truth control**: the request form keeps one dominant action, `Submit support request`, and reuses the support-diagnostics bundle summary instead of introducing local case language. On the operation detail page, both support actions (`Open support diagnostics`, `Request support`) stay grouped in secondary placement under `More`.
|
|
||||||
- **Handling modes by drift class or surface**: review-mandatory
|
|
||||||
- **Repository-signal treatment**: review-mandatory
|
|
||||||
- **Special surface test profiles**: standard-native-filament, monitoring-state-page
|
|
||||||
- **Required tests or manual smoke**: functional-core, state-contract
|
|
||||||
- **Exception path and spread control**: existing tenant dashboard action-surface exemption remains bounded to the two support-aware actions only
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
|
|
||||||
## Shared Pattern & System Fit
|
|
||||||
|
|
||||||
- **Cross-cutting feature marker**: yes
|
|
||||||
- **Systems touched**: `App\Filament\Pages\TenantDashboard`, `App\Filament\Pages\Operations\TenantlessOperationRunViewer`, `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder`, `App\Support\Auth\Capabilities`, `App\Services\Auth\RoleCapabilityMap`, and `App\Services\Audit\WorkspaceAuditLogger`
|
|
||||||
- **Shared abstractions reused**: support-diagnostics bundle composition, existing capability gating through `UiEnforcement`, existing audit-log writing, and the current support-aware tenant and run action surfaces
|
|
||||||
- **New abstraction introduced? why?**: one bounded `SupportRequestContextBuilder` or `SupportRequestSubmissionService` is justified because the slice needs one canonical place to shape immutable request context and reference generation without duplicating tenant/run logic
|
|
||||||
- **Why the existing abstraction was sufficient or insufficient**: the existing abstractions are sufficient for safe context and audit patterns, but insufficient for persisted support-request truth and immutable reference generation
|
|
||||||
- **Bounded deviation / spread control**: no ticket-provider adapter, no support queue, no page-local context builders, and no second support-summary vocabulary
|
|
||||||
|
|
||||||
## OperationRun UX Impact
|
|
||||||
|
|
||||||
- **Touches OperationRun start/completion/link UX?**: no
|
|
||||||
- **Central contract reused**: N/A
|
|
||||||
- **Delegated UX behaviors**: N/A
|
|
||||||
- **Surface-owned behavior kept local**: the run-context action reads the current run only as request context
|
|
||||||
- **Queued DB-notification policy**: N/A
|
|
||||||
- **Terminal notification path**: N/A
|
|
||||||
- **Exception path**: none
|
|
||||||
|
|
||||||
## Provider Boundary & Portability Fit
|
|
||||||
|
|
||||||
- **Shared provider/platform boundary touched?**: yes
|
|
||||||
- **Provider-owned seams**: translated provider reasons and provider-connection state already carried by the support-diagnostics bundle
|
|
||||||
- **Platform-core seams**: support request record, support reference, severity, primary context labels, attachment mode
|
|
||||||
- **Neutral platform terms / contracts preserved**: support request, support reference, attached context, redacted diagnostic snapshot, canonical reference set
|
|
||||||
- **Retained provider-specific semantics and why**: provider-specific reasons remain inside the redacted support-diagnostics bundle because they are already modeled as provider-owned evidence
|
|
||||||
- **Bounded extraction or follow-up path**: external ticketing remains a follow-up spec and must consume the neutral support-request truth instead of reshaping provider-specific evidence inline
|
|
||||||
|
|
||||||
## Constitution Check
|
|
||||||
|
|
||||||
*GATE: Must pass before implementation begins. Re-check after design changes.*
|
|
||||||
|
|
||||||
- Inventory-first / snapshots-second: PASS - the request stores immutable submitted context and does not replace canonical diagnostic truth
|
|
||||||
- Read/write separation: PASS - the action form is the pre-submit preview surface, creation is explicit, and the write is audited and tested
|
|
||||||
- Graph contract path: PASS - no new Graph calls are introduced
|
|
||||||
- Deterministic capabilities: PASS - capability derivation stays in the canonical registry and role map
|
|
||||||
- RBAC-UX / workspace isolation / tenant isolation: PASS - the feature remains on `/admin`, keeps non-member and non-entitled access as 404, and uses tenant-safe action surfaces only
|
|
||||||
- Global search rule: PASS - no resource or global-search change is introduced
|
|
||||||
- Run observability / Ops UX: PASS - request creation is DB-only and intentionally creates no `OperationRun`
|
|
||||||
- Proportionality / `PROP-001`, `ABSTR-001`, `PERSIST-001`, `STATE-001`, `BLOAT-001`: PASS - one persisted request truth and one small severity family are justified by structured support intake and no narrower path preserves immutable request context
|
|
||||||
- Shared pattern reuse / `XCUT-001`: PASS - support-diagnostics and audit seams are reused explicitly
|
|
||||||
- Provider boundary / `PROV-001`: PASS - provider-specific semantics stay inside the existing redacted bundle only
|
|
||||||
- Filament-native UI / `UI-FIL-001`: PASS - the slice uses native Filament action forms only
|
|
||||||
- Livewire v4 / Filament v5 compliance: PASS - the plan stays entirely within the current Filament v5 + Livewire v4 stack
|
|
||||||
- Provider registration location: PASS - no provider registration changes are introduced; Laravel 11+ provider registration remains in `bootstrap/providers.php`
|
|
||||||
- Destructive actions: PASS - none added, so no `->requiresConfirmation()` path is introduced here
|
|
||||||
- Asset strategy: PASS - no new assets are required, so deployment behavior for `cd apps/platform && php artisan filament:assets` remains unchanged
|
|
||||||
- Test governance / `TEST-GOV-001`: PASS - proof remains in narrow unit plus feature lanes only
|
|
||||||
|
|
||||||
## Test Governance Check
|
|
||||||
|
|
||||||
- **Test purpose / classification by changed surface**: Unit for context-shape and internal-reference rules; Feature for tenant and run action behavior, authorization, and audit
|
|
||||||
- **Affected validation lanes**: fast-feedback, confidence
|
|
||||||
- **Why this lane mix is the narrowest sufficient proof**: the feature is server-driven, synchronous, and action-form based; browser automation would only duplicate behavior already provable through unit and Filament feature tests
|
|
||||||
- **Narrowest proving command(s)**:
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/SupportRequestContextBuilderTest.php tests/Unit/Support/SupportRequests/SupportRequestReferenceTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestActionTest.php tests/Feature/SupportRequests/OperationRunSupportRequestActionTest.php tests/Feature/SupportRequests/SupportRequestAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestAuditTest.php`
|
|
||||||
- **Fixture / helper / factory / seed / context cost risks**: add one `SupportRequest` factory; reuse existing workspace, tenant, run, provider, finding, report, review, and audit fixtures; keep support-request helpers local to the feature namespace
|
|
||||||
- **Expensive defaults or shared helper growth introduced?**: no
|
|
||||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
|
||||||
- **Surface-class relief / special coverage rule**: standard-native-filament relief applies to the tenant action; the operation detail action keeps the monitoring-state-page contract already established on the viewer
|
|
||||||
- **Closing validation and reviewer handoff**: reviewers should re-run the listed unit and feature commands, verify immutable context persistence, verify 404 versus 403 boundaries, and verify that no outbound HTTP or `OperationRun` side effect occurs
|
|
||||||
- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep
|
|
||||||
- **Review-stop questions**: did implementation add an outbound adapter, a request status workflow, a support inbox, or raw diagnostic persistence; did it broaden entry points beyond the two approved surfaces?
|
|
||||||
- **Escalation path**: `reject-or-split` if the slice expands into external sync, a support queue, or lifecycle management
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Why no dedicated follow-up spec is needed**: the planned cost stays local to support-request creation; only external ticketing or request-lifecycle expansion would justify a follow-up spec
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Documentation (this feature)
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/246-support-request-context/
|
|
||||||
├── checklists/
|
|
||||||
│ └── requirements.md
|
|
||||||
├── spec.md
|
|
||||||
├── plan.md
|
|
||||||
└── tasks.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source Code (repository root)
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/platform/
|
|
||||||
├── app/
|
|
||||||
│ ├── Filament/
|
|
||||||
│ │ └── Pages/
|
|
||||||
│ │ ├── Operations/
|
|
||||||
│ │ │ └── TenantlessOperationRunViewer.php
|
|
||||||
│ │ └── TenantDashboard.php
|
|
||||||
│ ├── Models/
|
|
||||||
│ │ └── SupportRequest.php
|
|
||||||
│ ├── Services/
|
|
||||||
│ │ ├── Audit/WorkspaceAuditLogger.php
|
|
||||||
│ │ └── Auth/RoleCapabilityMap.php
|
|
||||||
│ └── Support/
|
|
||||||
│ ├── Audit/AuditActionId.php
|
|
||||||
│ ├── Auth/Capabilities.php
|
|
||||||
│ ├── SupportDiagnostics/SupportDiagnosticBundleBuilder.php
|
|
||||||
│ └── SupportRequests/
|
|
||||||
│ ├── SupportRequestContextBuilder.php
|
|
||||||
│ └── SupportRequestReferenceGenerator.php
|
|
||||||
├── database/
|
|
||||||
│ ├── factories/SupportRequestFactory.php
|
|
||||||
│ └── migrations/
|
|
||||||
└── tests/
|
|
||||||
├── Feature/SupportRequests/
|
|
||||||
│ ├── OperationRunSupportRequestActionTest.php
|
|
||||||
│ ├── SupportRequestAuditTest.php
|
|
||||||
│ ├── SupportRequestAuthorizationTest.php
|
|
||||||
│ └── TenantSupportRequestActionTest.php
|
|
||||||
└── Unit/Support/SupportRequests/
|
|
||||||
├── SupportRequestContextBuilderTest.php
|
|
||||||
└── SupportRequestReferenceTest.php
|
|
||||||
```
|
|
||||||
|
|
||||||
**Structure Decision**: Single Laravel application. The implementation adds one bounded support-request model plus one small support namespace and reuses existing tenant and run surfaces.
|
|
||||||
|
|
||||||
## Complexity Tracking
|
|
||||||
|
|
||||||
No additional constitution violations are required. The new persisted support-request truth and small severity family are already justified in the proportionality review and remain bounded to the first slice.
|
|
||||||
|
|
||||||
## Proportionality Review
|
|
||||||
|
|
||||||
- **Current operator problem**: support intake still starts outside structured product context even though the product can already explain the current tenant or run problem safely
|
|
||||||
- **Existing structure is insufficient because**: support diagnostics is read-only and cannot provide an immutable, auditable support request or internal support reference
|
|
||||||
- **Narrowest correct implementation**: one immutable `SupportRequest` record plus one bounded context builder that reuses existing support diagnostics when allowed
|
|
||||||
- **Ownership cost**: migration, model, factory, capability, audit action, bounded support-request support namespace, and focused tests
|
|
||||||
- **Alternative intentionally rejected**: outbound-only ticket adapters, a multi-provider helpdesk registry, and a request status workflow were rejected because no concrete second use case or provider foundation exists yet
|
|
||||||
- **Why this is current-release truth**: the support-request record is directly useful now even without external sync because it preserves structured intake, returns a support reference, and captures immutable context
|
|
||||||
|
|
||||||
## Rollout & Risk Controls
|
|
||||||
|
|
||||||
- Start on exactly two existing surfaces only: the tenant dashboard and the canonical operation detail viewer
|
|
||||||
- Keep request truth immutable after creation; lifecycle management is explicitly out of scope
|
|
||||||
- Persist only redacted context JSON; raw payloads, secrets, and unrestricted provider bodies remain excluded
|
|
||||||
- Keep the reference internal-only in v1; external ticket references are deferred until a later ticketing spec exists
|
|
||||||
- Reuse the existing support-diagnostics capability when attaching diagnostics so the feature can safely support reduced attachment mode without creating a second diagnostic access path
|
|
||||||
|
|
||||||
## Implementation Outline
|
|
||||||
|
|
||||||
- Add the `SupportRequest` model, migration, and factory as the new persisted support-intake truth
|
|
||||||
- Add `support_requests.create` to the canonical capability registry and role map
|
|
||||||
- Add a bounded support-request context builder plus internal reference generator that can build tenant-context and run-context envelopes from the existing support-diagnostics bundle and canonical references
|
|
||||||
- Add an audit action identifier and `WorkspaceAuditLogger` path for support-request creation
|
|
||||||
- Add `Request support` action forms to `TenantDashboard` and `TenantlessOperationRunViewer`
|
|
||||||
- Return the generated support reference in success feedback and keep the action synchronous and DB-only
|
|
||||||
|
|
||||||
## Implementation Phases
|
|
||||||
|
|
||||||
1. **Foundation**: migration, model, factory, capability, audit action, internal reference generation, and shared context builder
|
|
||||||
2. **Tenant entry point**: tenant dashboard action, reduced-attachment logic, tenant-context feature proof
|
|
||||||
3. **Run entry point**: canonical operation detail action, entitled-tenant resolution, run-context feature proof
|
|
||||||
4. **Safety hardening**: immutable context persistence, authorization edge cases, audit proof, and no-side-effect verification
|
|
||||||
|
|
||||||
## Guardrail Close-Out
|
|
||||||
|
|
||||||
- Livewire v4 compliance remained unchanged and the feature shipped through native Filament v5 action forms only.
|
|
||||||
- Provider registration location stayed unchanged; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
|
|
||||||
- No global-search behavior changed because no resource or global-search surface was introduced.
|
|
||||||
- No destructive action was added, so no new confirmation flow was required.
|
|
||||||
- No asset strategy changed; deployment remains unchanged, including `cd apps/platform && php artisan filament:assets` when registered Filament assets are present elsewhere.
|
|
||||||
- Browser smoke confirmed both approved entry points on the live app: the tenant dashboard visible `Request support` action and the tenantless operation viewer grouped `More` > `Request support` action both submitted successfully and returned `Support request submitted` with `Reference SR-...`.
|
|
||||||
- The tenant dashboard action-surface exception stayed bounded to the existing support-aware pair (`Request support`, `Open support diagnostics`), while the operation viewer kept both support actions grouped under `More` to avoid competing with its primary navigation and utility actions.
|
|
||||||
@ -1,281 +0,0 @@
|
|||||||
# Feature Specification: In-App Support Request with Context
|
|
||||||
|
|
||||||
**Feature Branch**: `246-support-request-context`
|
|
||||||
**Created**: 2026-04-27
|
|
||||||
**Status**: Ready for implementation
|
|
||||||
**Input**: User description: "Promote the roadmap-fit candidate In-App Support Request with Context as a narrow, implementation-ready slice that adds structured support-request creation from existing support-aware product surfaces. The slice should reuse Support Diagnostic Pack, product knowledge, existing tenant and operation context resolution, and audit patterns to capture one support request with redacted diagnostic references and operator-entered severity/message fields, without building a full helpdesk, ticket sync engine, or CRM workflow."
|
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
|
||||||
|
|
||||||
- **Problem**: Generic support email or an external ticket link discards the most important product context at the moment help is requested: workspace, tenant, current run, related findings, recent reports, review artifacts, and the current diagnostic state.
|
|
||||||
- **Today's failure**: Operators must manually copy context from several product surfaces into an ad hoc support note, which creates avoidable back-and-forth, invites oversharing of raw diagnostics, and leaves no reliable internal support reference inside TenantPilot.
|
|
||||||
- **User-visible improvement**: A tenant-scoped or run-scoped operator can submit one structured support request from the current product surface, capture severity plus summary and note fields, attach safe context automatically, and immediately receive an internal support reference.
|
|
||||||
- **Smallest enterprise-capable version**: Add one immutable internal `SupportRequest` record with a generated reference, two first-slice entry actions on existing tenant and run surfaces, automatic canonical context attachment, optional redacted support-diagnostic snapshot attachment when the creator is allowed to view it, and audit logging for request creation.
|
|
||||||
- **Explicit non-goals**: No full helpdesk product, no support inbox or triage board, no two-way ticket sync, no SLA engine, no file-upload pipeline, no customer-facing portal, no AI support bot, no background notification workflow, and no external ticket provider coupling in v1.
|
|
||||||
- **Permanent complexity imported**: One new persisted `SupportRequest` truth, one small support-request severity family, one bounded context-builder path, one generated internal reference format, one new tenant capability, two read-only context-aware create actions, and focused unit plus feature coverage.
|
|
||||||
- **Why now**: The repo already has the support-diagnostics bundle, contextual help, tenant and run context resolution, and audit seams. This is the narrowest next slice that turns those support foundations into structured intake instead of leaving support creation as manual copy-paste.
|
|
||||||
- **Why not local**: A page-local note field, mailto link, or generic modal would still duplicate context assembly, drift redaction behavior, and lose canonical references. The support request needs one reusable capture contract because both tenant and run surfaces already depend on the same support truth.
|
|
||||||
- **Approval class**: Workflow Compression
|
|
||||||
- **Red flags triggered**: New persistence, new severity semantics, and multi-surface workflow touchpoint. Defense: the slice stores one immutable request record only, avoids status workflow or external sync, and is limited to two already support-aware surfaces that can reuse the existing diagnostic bundle.
|
|
||||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
|
||||||
- **Decision**: approve
|
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
|
||||||
|
|
||||||
- **Scope**: workspace, tenant
|
|
||||||
- **Primary Routes**:
|
|
||||||
- `/admin/t/{tenant}` existing tenant dashboard as the first tenant-context support-request entry point
|
|
||||||
- `/admin/operations/{run}` existing canonical operation detail surface as the first run-context support-request entry point
|
|
||||||
- **Data Ownership**: `support_requests` becomes new tenant-owned product truth with required `workspace_id` and `tenant_id`, both stored as not-null fields. `workspace_id` is still derived from the tenant relationship for correctness, but it remains persisted because tenant-owned truth in this repo carries both anchors. Canonical source truth for attached references remains on existing `Tenant`, `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack`, and `AuditLog` records. Any attached support-diagnostic snapshot is a redacted immutable capture owned by the support request, not a replacement for those source records.
|
|
||||||
- **RBAC**: Workspace membership and tenant entitlement remain mandatory. A new tenant-role capability `support_requests.create` gates request creation. When a request includes the redacted support-diagnostic snapshot, the creator must also pass the existing `support_diagnostics.view` check; otherwise the request stores only the safe canonical context reference set and an explicit note that diagnostic evidence was omitted.
|
|
||||||
|
|
||||||
For canonical-view specs, the spec MUST define:
|
|
||||||
|
|
||||||
- **Default filter behavior when tenant-context is active**: N/A - the first slice adds contextual create actions only and does not introduce a support-request registry page.
|
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Non-members and non-entitled users receive 404 semantics before any tenant-owned context is assembled. The operation entry point is in scope only when the run resolves to an entitled tenant.
|
|
||||||
|
|
||||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
|
||||||
|
|
||||||
- **Cross-cutting feature?**: yes
|
|
||||||
- **Interaction class(es)**: header actions, contextual support capture, success notifications, support-safe context summaries, audit events
|
|
||||||
- **Systems touched**: existing tenant dashboard and canonical operation detail action surfaces, the support-diagnostics bundle builder, contextual-help content, tenant capability mapping, and workspace audit logging
|
|
||||||
- **Existing pattern(s) to extend**: existing support-diagnostics entry points, existing Filament header action patterns, existing support-diagnostics redaction rules, and existing audit logging conventions
|
|
||||||
- **Shared contract / presenter / builder / renderer to reuse**: `SupportDiagnosticBundleBuilder`, `WorkspaceAuditLogger`, existing contextual-help resolver or catalog patterns where copy is needed, and existing support-safe route and label vocabulary already used on tenant and run surfaces
|
|
||||||
- **Why the existing shared path is sufficient or insufficient**: The current shared support paths already produce deterministic, redacted context and the right canonical references. They are insufficient because the product still lacks a persisted support-request truth and a structured create flow that can capture that context safely.
|
|
||||||
- **Allowed deviation and why**: One bounded `SupportRequests` support namespace is allowed to assemble immutable request context and generate an internal support reference. No generic ticket adapter, no local page-only context mappers, and no second support-summary dialect are allowed.
|
|
||||||
- **Consistency impact**: `Request support`, `Support reference`, redaction wording, severity labels, and attached-context copy must remain consistent across tenant and run entry points so the same support concept is visible regardless of where the request originates.
|
|
||||||
- **Review focus**: Reviewers must verify that context capture reuses the shared support-diagnostics bundle rather than rebuilding record selection locally, and that no raw provider payloads or unrestricted diagnostics are persisted in the request.
|
|
||||||
|
|
||||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
|
||||||
|
|
||||||
- **Touches OperationRun start/completion/link UX?**: no
|
|
||||||
- **Shared OperationRun UX contract/layer reused**: N/A
|
|
||||||
- **Delegated start/completion UX behaviors**: N/A
|
|
||||||
- **Local surface-owned behavior that remains**: The run-context request action reads the current run as context only; it does not queue, resume, or complete any `OperationRun`.
|
|
||||||
- **Queued DB-notification policy**: N/A
|
|
||||||
- **Terminal notification path**: N/A
|
|
||||||
- **Exception required?**: none
|
|
||||||
|
|
||||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
|
||||||
|
|
||||||
- **Shared provider/platform boundary touched?**: yes
|
|
||||||
- **Boundary classification**: mixed
|
|
||||||
- **Seams affected**: provider-connection health excerpts that may appear inside an attached redacted support-diagnostic snapshot, provider-owned reason translation, and support-request context labels
|
|
||||||
- **Neutral platform terms preserved or introduced**: support request, support reference, primary context, attached context, redacted diagnostic snapshot, canonical reference set
|
|
||||||
- **Provider-specific semantics retained and why**: Microsoft-specific provider errors or consent states remain provider-owned diagnostic inputs and may only appear through the already redacted support-diagnostics bundle.
|
|
||||||
- **Why this does not deepen provider coupling accidentally**: The new request record stores provider-neutral context metadata plus canonical references. Provider-specific semantics enter only through the existing support-diagnostics bundle, which is already bounded and redacted.
|
|
||||||
- **Follow-up path**: Later PSA or ticketing integration remains a separate follow-up spec and must consume the neutral support-request truth instead of reshaping it.
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
|
||||||
|
|
||||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| Tenant dashboard request support action | yes | Native Filament + shared support primitives | header actions, support capture, support diagnostics | page, action, form | yes | Existing tenant dashboard action-surface exemption remains; this slice adds one bounded create action beside the current support-diagnostics action |
|
|
||||||
| Canonical operation detail request support action | yes | Native Filament + shared support primitives | header actions, monitoring-state diagnostics, support capture | detail, action, form | no | Extends an already support-aware operation surface instead of creating a new support page |
|
|
||||||
|
|
||||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| Tenant dashboard request support action | Secondary Context Surface | The operator decides the current tenant issue needs escalation or structured support intake | Tenant identity, context summary, severity selection, message fields, and whether redacted diagnostics will be attached | Redacted support diagnostics and canonical related records remain secondary evidence | Not primary because support creation follows tenant troubleshooting rather than replacing it | Follows tenant troubleshooting and escalation | Eliminates manual copy-paste from provider, run, findings, and audit surfaces |
|
|
||||||
| Canonical operation detail request support action | Secondary Context Surface | The operator is already inspecting one run and decides support intake should begin from that run context | Run identity, context summary, severity selection, message fields, and whether redacted diagnostics will be attached | Redacted support diagnostics and canonical related records remain secondary evidence | Not primary because the main work is still understanding and acting on the run | Follows monitoring drill-in workflow | Removes ad hoc support notes that would otherwise rebuild run context manually |
|
|
||||||
|
|
||||||
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| 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 |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| Tenant dashboard request support action | operator-MSP, support-platform | Scope summary, severity, message fields, contact defaults, attachment note, redaction note | Existing support-diagnostics bundle and canonical links | Raw payloads and unrestricted provider diagnostics are never stored here | `Submit support request` | Diagnostic snapshot attachment is omitted unless `support_diagnostics.view` is allowed | The action reuses the shared support-diagnostics bundle summary instead of restating provider or run truth locally |
|
|
||||||
| Canonical operation detail request support action | operator-MSP, support-platform | Run identity, severity, message fields, contact defaults, attachment note, redaction note | Existing run-context support diagnostics and canonical links | Raw payloads and unrestricted provider diagnostics are never stored here | `Submit support request` | Diagnostic snapshot attachment is omitted unless `support_diagnostics.view` is allowed | The action reuses the shared run-context support summary instead of inventing a second run explanation path |
|
|
||||||
|
|
||||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Tenant dashboard request support action | Dashboard / Overview / Actions | Tenant support escalation entry point | Submit a structured support request | Explicit header action opens a slide-over or modal form | forbidden | Existing support diagnostics remains a neighboring secondary action | none | `/admin/t/{tenant}` | `/admin/t/{tenant}` | Active workspace, active tenant, and attached-context note | Support request / Support reference | Primary context, required message fields, severity, and redaction note | dashboard_exception - existing tenant dashboard action-surface exemption remains bounded and read-only apart from the create mutation |
|
|
||||||
| Canonical operation detail request support action | Record / Detail / Actions | Run-centered support escalation entry point | Submit a structured support request from the current run | Existing detail page plus grouped secondary support actions | forbidden | `Open support diagnostics` and `Request support` are grouped together under the detail action group | none | `/admin/operations` | `/admin/operations/{run}` | Workspace context, entitled tenant context, and operation identifier | Support request / Support reference | Primary run context, required summary fields, severity, and redaction note | none |
|
|
||||||
|
|
||||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Tenant dashboard request support action | Workspace manager or support-capable tenant operator | Submit one tenant-scoped support request with safe context | Dashboard action + contextual form | How do I ask for help on this tenant without manually rebuilding the case? | Tenant label, severity, summary, reproduction-notes field, contact defaults, context-attachment note, redaction note | Full support diagnostics stay on the neighboring support-diagnostics action and canonical linked pages | support-request severity, attachment completeness | TenantPilot only | Submit support request | none |
|
|
||||||
| Canonical operation detail request support action | Workspace manager or support-capable operator | Submit one run-scoped support request with safe context | Detail action + contextual form | How do I escalate this run with the right context and without copying raw diagnostics? | Operation identifier, severity, summary, reproduction-notes field, contact defaults, context-attachment note, redaction note | Full support diagnostics stay on the neighboring support-diagnostics action and canonical linked pages | support-request severity, attachment completeness | TenantPilot only | Submit support request | none |
|
|
||||||
|
|
||||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
|
||||||
|
|
||||||
- **New source of truth?**: yes
|
|
||||||
- **New persisted entity/table/artifact?**: yes
|
|
||||||
- **New abstraction?**: yes
|
|
||||||
- **New enum/state/reason family?**: yes - one bounded support-request severity family
|
|
||||||
- **New cross-domain UI framework/taxonomy?**: no
|
|
||||||
- **Current operator problem**: The product can already explain support context, but it still cannot capture a structured support request at the moment the operator needs help.
|
|
||||||
- **Existing structure is insufficient because**: Support diagnostics is read-only and generic support channels lose context. Without a persisted request record, there is no internal support reference or safe immutable request payload.
|
|
||||||
- **Narrowest correct implementation**: Add one immutable `SupportRequest` truth with a small severity family and one bounded context-builder path that reuses the existing support-diagnostics bundle when allowed.
|
|
||||||
- **Ownership cost**: One migration, one model and factory, one support-request context builder or submission service, one capability, one audit action, and focused unit plus feature coverage.
|
|
||||||
- **Alternative intentionally rejected**: Outbound-only ticket adapters and a broader helpdesk workflow were rejected because the repo has no concrete ticket-provider foundation yet and the first release does not need a support queue, lifecycle engine, or external sync.
|
|
||||||
- **Release truth**: current-release truth
|
|
||||||
|
|
||||||
### Compatibility posture
|
|
||||||
|
|
||||||
This feature assumes a pre-production environment.
|
|
||||||
|
|
||||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
|
||||||
|
|
||||||
Canonical replacement is preferred over preservation.
|
|
||||||
|
|
||||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
|
||||||
|
|
||||||
- **Test purpose / classification**: Unit, Feature
|
|
||||||
- **Validation lane(s)**: fast-feedback, confidence
|
|
||||||
- **Why this classification and these lanes are sufficient**: Unit tests can prove internal reference generation, immutable context-shape rules, and redaction-aware attachment behavior. Feature tests can prove tenant and run entry actions, 404 versus 403 boundaries, persisted request creation, and audit logging without browser automation.
|
|
||||||
- **New or expanded test families**: One focused `SupportRequests` unit family and a small set of tenant plus run feature tests
|
|
||||||
- **Fixture / helper cost impact**: Moderate. Reuse existing workspace, tenant, run, provider, finding, stored-report, review, audit, and support-diagnostics fixtures. Add one `SupportRequest` factory and keep helpers local to the support-request test namespace.
|
|
||||||
- **Heavy-family visibility / justification**: none
|
|
||||||
- **Special surface test profile**: standard-native-filament, monitoring-state-page
|
|
||||||
- **Standard-native relief or required special coverage**: Ordinary Filament feature coverage is sufficient for the tenant dashboard action. The run-context action must also preserve the canonical monitoring-state-page constraints already used on the existing operation viewer.
|
|
||||||
- **Reviewer handoff**: Reviewers must confirm that request creation persists only redacted context, never triggers outbound HTTP or a new `OperationRun`, returns the internal support reference in success feedback, and keeps non-member access as 404.
|
|
||||||
- **Budget / baseline / trend impact**: Low-to-moderate increase in narrow unit plus feature coverage only
|
|
||||||
- **Escalation needed**: none
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Planned validation commands**:
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/SupportRequestContextBuilderTest.php tests/Unit/Support/SupportRequests/SupportRequestReferenceTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestActionTest.php tests/Feature/SupportRequests/OperationRunSupportRequestActionTest.php tests/Feature/SupportRequests/SupportRequestAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestAuditTest.php`
|
|
||||||
|
|
||||||
## Support-Request Severity Contract
|
|
||||||
|
|
||||||
The first slice uses one fixed severity family and does not infer or automate downstream workflow from it.
|
|
||||||
|
|
||||||
| Stored Value | UI Label | Meaning In V1 |
|
|
||||||
|---|---|---|
|
|
||||||
| `low` | Low | Question or minor issue; work can continue and no current blocker exists |
|
|
||||||
| `normal` | Normal | Ordinary support needed; work can continue with friction |
|
|
||||||
| `high` | High | Time-sensitive issue or material degradation that needs prompt attention |
|
|
||||||
| `blocking` | Blocking | Current work cannot proceed or access is effectively blocked |
|
|
||||||
|
|
||||||
Validation rules for v1:
|
|
||||||
|
|
||||||
- Exactly one severity value is required on every support request.
|
|
||||||
- The action form defaults to `normal` unless the operator selects a different value.
|
|
||||||
- Severity affects persisted request truth and UI labels only in v1; it does not start a queue, SLA, or notification workflow.
|
|
||||||
|
|
||||||
## Internal Support Reference Contract
|
|
||||||
|
|
||||||
- Every created support request receives a reference formatted as `SR-<ULID>`.
|
|
||||||
- The `SR-` prefix is fixed and uppercase in v1.
|
|
||||||
- The ULID portion is uppercase, generated once at creation time, and remains immutable afterward.
|
|
||||||
- Success feedback and audit context must show the exact stored value rather than reformatting it locally.
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - Submit a tenant-scoped support request with safe context (Priority: P1)
|
|
||||||
|
|
||||||
As a workspace manager or support-capable tenant operator, I want to request support from the current tenant surface so I do not have to manually rebuild the case.
|
|
||||||
|
|
||||||
**Why this priority**: Tenant-context support intake is the broadest first-response path and benefits immediately from the existing support-diagnostics foundation.
|
|
||||||
|
|
||||||
**Independent Test**: Seed a tenant with provider, run, finding, review, report, and audit truth, submit a support request from the tenant dashboard, and verify that the request is persisted with an internal reference and only safe attached context.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an entitled operator opens the tenant dashboard for a tenant with current support context, **When** they submit a support request with severity and summary, **Then** the system creates one immutable support request with a generated internal reference, tenant and workspace context, and canonical references to the current case.
|
|
||||||
2. **Given** the creator also has `support_diagnostics.view`, **When** they submit the tenant-scoped request, **Then** the request stores the redacted support-diagnostics snapshot or reference set instead of requiring manual copy-paste.
|
|
||||||
3. **Given** the creator lacks `support_diagnostics.view` but still has `support_requests.create`, **When** they submit the request, **Then** the request stores the safe canonical context set only and records that diagnostic evidence was omitted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Submit a run-scoped support request from monitoring (Priority: P1)
|
|
||||||
|
|
||||||
As an operator already inspecting one run, I want to request support from the canonical run detail surface so the support request starts from the exact failing or degraded run.
|
|
||||||
|
|
||||||
**Why this priority**: A large share of support work starts from one run, and the operation viewer already has the right support-aware context and authorization boundaries.
|
|
||||||
|
|
||||||
**Independent Test**: Seed a failed or degraded run with related canonical truth, submit a support request from the operation viewer, and verify that the saved request is run-scoped, tenant-safe, and auditable.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an entitled operator is viewing a run that resolves to an entitled tenant, **When** they submit a support request from that run surface, **Then** the system persists one immutable support request whose primary context is that run and whose canonical references stay tenant-safe.
|
|
||||||
2. **Given** the run does not resolve to an entitled tenant for the current user, **When** they try to create a run-scoped support request, **Then** the system responds as not found and does not reveal whether additional support context exists.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Keep support intake redacted, auditable, and bounded (Priority: P2)
|
|
||||||
|
|
||||||
As the product owner, I want the first support-request slice to stay immutable and provider-neutral so it can be trusted and extended later without accidentally becoming a helpdesk framework.
|
|
||||||
|
|
||||||
**Why this priority**: Structured intake adds value only if it stays safe, deterministic, and free of premature helpdesk complexity.
|
|
||||||
|
|
||||||
**Independent Test**: Verify that repeated request creation uses the same reference format and context-shape rules, that audit entries are written, and that no outbound helpdesk work or `OperationRun` side effects occur.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the same authorized tenant or run input and the same creator capability set, **When** a support request is generated, **Then** the attached context shape follows the same deterministic rules every time.
|
|
||||||
2. **Given** support request creation succeeds, **When** the request is persisted, **Then** an audit entry records the actor, primary context, internal reference, and redaction mode without storing raw provider payloads.
|
|
||||||
3. **Given** an operator submits the same issue twice, **When** both requests are accepted, **Then** each submission receives its own immutable internal reference because v1 intentionally does not deduplicate or merge requests.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- A tenant may have no provider connection, no recent run, or no active findings. The support request must still persist with explicit `missing` or `not observed` context markers rather than failing or inventing placeholder truth.
|
|
||||||
- A run may reference related records that have since been deleted or are no longer accessible. The saved request must keep safe missing or inaccessible markers without leaking details from those records.
|
|
||||||
- A creator may have `support_requests.create` without `support_diagnostics.view`. The request must still be creatable with the reduced safe context set and explicit omission semantics.
|
|
||||||
- Context may change after request creation. The request must preserve the immutable submitted context snapshot or reference envelope and must not silently mutate with later diagnostic state.
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
**Constitution alignment (required):** This feature introduces a DB-only write and no new Microsoft Graph calls, no scheduled work, and no queued workflow. Because the request is security-relevant and intentionally skips `OperationRun`, successful request creation MUST write an `AuditLog` entry with redacted metadata.
|
|
||||||
|
|
||||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces one new persisted support-request truth, one bounded support-request context builder or submission service, and one small support-request severity family because existing support diagnostics is read-only and generic ticket links lose context. A narrower solution is insufficient because it would still fail to preserve immutable submitted context or return a trustworthy in-product support reference.
|
|
||||||
|
|
||||||
**Constitution alignment (XCUT-001):** Support-request capture MUST extend the existing support-diagnostics bundle and audit paths rather than duplicating page-local context assembly or provider wording.
|
|
||||||
|
|
||||||
**Constitution alignment (PROV-001):** The support-request model and labels remain provider-neutral. Provider-specific semantics may appear only inside the already redacted support-diagnostic snapshot when that attachment is allowed.
|
|
||||||
|
|
||||||
**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit plus feature lanes only. Browser and heavy-governance lanes are out of scope for the first slice.
|
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX / OPS-UX-START-001):** No new `OperationRun` is created, resumed, or linked as a started workflow. Request creation is a synchronous DB-only support action.
|
|
||||||
|
|
||||||
**Constitution alignment (RBAC-UX):** The affected authorization plane is the tenant-admin `/admin` plane. Non-members and non-entitled users receive 404. Entitled members lacking `support_requests.create` receive 403. The run-context action must only render after the viewer resolves an entitled tenant scope.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-FIL-001):** The feature must use native Filament actions and action forms. No custom standalone support page or ad hoc support form shell is allowed in v1.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** Primary operator labels remain `Request support` and `Support reference`. Implementation-first terms such as payload snapshot, JSON blob, or ticket adapter must not appear in the primary UI.
|
|
||||||
|
|
||||||
**Constitution alignment (DECIDE-001 / OPSURF-001):** The affected surfaces remain secondary context surfaces. Support creation must not compete with the primary tenant or operation investigation workflow and must not expose raw diagnostics by default.
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-246-001 Entry points**: The system MUST allow support-request creation from exactly two first-slice contexts: the existing tenant dashboard and the existing canonical operation detail surface.
|
|
||||||
- **FR-246-002 Immutable internal reference**: Every created support request MUST receive a generated internal support reference that is unique, stable, and shown back to the creator immediately after submission.
|
|
||||||
- **FR-246-003 Captured fields**: A support request MUST capture workspace, tenant, initiating user, primary context type, optional run reference when applicable, severity chosen from `low`, `normal`, `high`, or `blocking`, a required summary field, optional reproduction notes, and optional contact name and contact email defaults derived from the creator.
|
|
||||||
- **FR-246-004 Canonical context attachment**: The request MUST automatically attach the safe canonical context set for the current tenant or run instead of requiring manual copy-paste of record identifiers.
|
|
||||||
- **FR-246-005 Diagnostic snapshot attachment**: When the creator can view support diagnostics for the current scope, the request MUST attach a redacted support-diagnostic snapshot or structured reference envelope derived from the existing bundle contract.
|
|
||||||
- **FR-246-006 Reduced attachment mode**: When the creator cannot view support diagnostics but can create a support request, the request MUST omit the diagnostic snapshot and persist only the safe canonical context set together with an explicit omission marker.
|
|
||||||
- **FR-246-007 Authorization boundaries**: Non-members and non-entitled users MUST receive 404 semantics. Entitled users lacking `support_requests.create` MUST receive 403. Cross-tenant or unrelated run context MUST never attach accidentally.
|
|
||||||
- **FR-246-008 No external dependency**: The first slice MUST NOT require an external ticket provider, outbound HTTP call, or external ticket reference to create a support request.
|
|
||||||
- **FR-246-009 Auditability**: Support-request creation MUST write an audit event that records the actor, support reference, primary context, attachment mode, and redaction mode without storing excluded raw payload content.
|
|
||||||
- **FR-246-010 Provider-safe persistence**: Persisted support-request context MUST NOT include raw provider payloads, secrets, tokens, or unrestricted diagnostic bodies.
|
|
||||||
- **FR-246-011 Immutability**: The first slice MUST treat the support request as immutable after creation. No edit, status workflow, reopen, or merge behavior may be introduced in v1.
|
|
||||||
- **FR-246-012 Duplicate submissions**: The first slice MUST allow repeated submissions and create a new internal support reference each time rather than attempting hidden deduplication or merge logic.
|
|
||||||
- **FR-246-013 Machine-readable context**: The attached context envelope MUST remain machine-readable and deterministic so later ticketing or AI-assisted follow-up can reuse it without redefining support-request truth.
|
|
||||||
|
|
||||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
|
||||||
|
|
||||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Tenant dashboard support actions | `App\Filament\Pages\TenantDashboard` | Existing `Open support diagnostics` plus new `Request support` | n/a | none | none | none added | `Request support` on the tenant dashboard action surface only | action-form submit plus cancel | yes | Existing tenant dashboard exemption remains bounded to two support-aware actions only |
|
|
||||||
| Canonical operation detail support actions | `App\Filament\Pages\Operations\TenantlessOperationRunViewer` | `Open support diagnostics` and `Request support` are grouped under `More` | Existing operation detail page remains the primary inspect model | none | none | n/a | Both support actions are grouped under the run detail action group instead of competing with the page's primary navigation and utility actions | action-form submit plus cancel | yes | Keeps support tooling secondary on the detail page while preserving structured escalation and diagnostics access |
|
|
||||||
|
|
||||||
### Key Entities *(include if feature involves data)*
|
|
||||||
|
|
||||||
- **SupportRequest**: The new immutable product truth that records one submitted support intake event together with its generated internal reference and safe attached context.
|
|
||||||
- **SupportRequest Context Envelope**: The redacted, machine-readable set of canonical references and optional diagnostic snapshot attached to a support request.
|
|
||||||
- **Support Reference**: The generated internal identifier shown back to the creator immediately after submission.
|
|
||||||
- **Attachment Mode**: The persisted marker that distinguishes between `diagnostic_snapshot_attached` and `canonical_context_only` based on creator capability and safe context rules.
|
|
||||||
|
|
||||||
## Success Criteria *(mandatory)*
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-246-001**: A support request created from either approved surface is persisted with a unique internal support reference and the expected primary context every time in focused feature coverage.
|
|
||||||
- **SC-246-002**: Focused authorization tests prove that unrelated tenant or run context cannot be attached accidentally and that 404 versus 403 boundaries remain correct.
|
|
||||||
- **SC-246-003**: Focused audit tests prove that support-request creation writes one redacted audit event and performs no outbound HTTP or `OperationRun` side effect.
|
|
||||||
- **SC-246-004**: Focused unit coverage proves that the attached context envelope remains deterministic and excludes raw provider payload content.
|
|
||||||
@ -1,185 +0,0 @@
|
|||||||
---
|
|
||||||
|
|
||||||
description: "Task list for In-App Support Request with Context"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Tasks: In-App Support Request with Context
|
|
||||||
|
|
||||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/246-support-request-context/`
|
|
||||||
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/246-support-request-context/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/246-support-request-context/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/246-support-request-context/checklists/requirements.md` (required)
|
|
||||||
|
|
||||||
**Tests (TEST-GOV-001)**: REQUIRED (Pest) for all runtime behavior changes in this slice. Keep proof in focused unit plus feature lanes only.
|
|
||||||
**Operations**: This slice must not create, queue, resume, or complete an `OperationRun`. Support-request creation stays DB-only and audited.
|
|
||||||
**RBAC**: Workspace membership, tenant entitlement, and `support_requests.create` remain authoritative. `support_diagnostics.view` controls only whether the redacted diagnostic snapshot can be attached.
|
|
||||||
**Organization**: Tasks are grouped by user story so tenant-context creation, run-context creation, and safety hardening remain independently verifiable once the shared foundation exists.
|
|
||||||
|
|
||||||
## Phase 1: Setup (Shared Infrastructure)
|
|
||||||
|
|
||||||
**Purpose**: Lock the first-slice scope and verify the existing support-aware seams before runtime edits begin.
|
|
||||||
|
|
||||||
- [x] T001 Review the first-slice scope, attachment modes, and validation commands in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/246-support-request-context/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/246-support-request-context/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/246-support-request-context/checklists/requirements.md`
|
|
||||||
- [x] T002 [P] Verify the current tenant dashboard, canonical operation detail viewer, support-diagnostics bundle builder, tenant capability map, and workspace audit logger seams that this slice must reuse in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`, `apps/platform/app/Support/Auth/Capabilities.php`, `apps/platform/app/Services/Auth/RoleCapabilityMap.php`, and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Prerequisites)
|
|
||||||
|
|
||||||
**Purpose**: Add the persisted support-request truth and shared capture path required by both entry contexts before surface wiring begins.
|
|
||||||
|
|
||||||
**Critical**: No user story work should start until this phase is complete.
|
|
||||||
|
|
||||||
- [x] T003 Create the tenant-owned `support_requests` migration, model, and factory with required not-null `workspace_id` and `tenant_id`, immutable internal reference, primary context type, severity constrained to `low`, `normal`, `high`, or `blocking`, required summary, optional reproduction notes, optional contact name and contact email, creator metadata, and redacted context JSON in `apps/platform/database/migrations/`, `apps/platform/app/Models/SupportRequest.php`, and `apps/platform/database/factories/SupportRequestFactory.php`
|
|
||||||
- [x] T004 [P] Register `support_requests.create` in the canonical tenant capability registry and tenant role map without widening system-plane access in `apps/platform/app/Support/Auth/Capabilities.php` and `apps/platform/app/Services/Auth/RoleCapabilityMap.php`
|
|
||||||
- [x] T005 [P] Add the bounded support-request context builder and internal reference generator in `apps/platform/app/Support/SupportRequests/SupportRequestContextBuilder.php` and `apps/platform/app/Support/SupportRequests/SupportRequestReferenceGenerator.php`, reusing `SupportDiagnosticBundleBuilder` for attachment-mode decisions and machine-readable context shaping while emitting `SR-<ULID>` references only
|
|
||||||
- [x] T006 [P] Add the support-request audit action identifier and workspace audit logger path for redacted request-creation metadata in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
|
|
||||||
|
|
||||||
**Checkpoint**: Foundation ready - both entry contexts can create the same immutable support-request truth through one shared capture path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: User Story 1 - Submit A Tenant-Scoped Support Request (Priority: P1) 🎯 MVP
|
|
||||||
|
|
||||||
**Goal**: An entitled operator can request support from the tenant dashboard without manually rebuilding the current case.
|
|
||||||
|
|
||||||
**Independent Test**: Seed a tenant with provider, run, finding, review, report, and audit truth, submit a request from the tenant dashboard, and verify persisted request context plus returned internal reference.
|
|
||||||
|
|
||||||
### Tests for User Story 1
|
|
||||||
|
|
||||||
- [x] T007 [P] [US1] Add tenant-context feature coverage for created support reference, persisted tenant primary context, persisted severity and optional reproduction/contact fields, reduced-attachment mode, and `404` versus `403` semantics in `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestActionTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 1
|
|
||||||
|
|
||||||
- [x] T008 [US1] Add the native Filament `Request support` action form to `apps/platform/app/Filament/Pages/TenantDashboard.php` with required severity and summary fields, optional reproduction notes, contact name and contact email defaults, attachment summary, and success notification carrying the internal support reference
|
|
||||||
- [x] T009 [US1] Implement tenant-context request capture in `apps/platform/app/Support/SupportRequests/SupportRequestContextBuilder.php` so the request stores canonical tenant references and attaches the redacted support-diagnostics snapshot only when allowed
|
|
||||||
- [x] T010 [US1] Ensure tenant-context submission persists the explicit `canonical_context_only` attachment mode instead of blocking the request when diagnostic attachment is unavailable
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 1 is independently functional when a tenant-context request can be submitted safely and the creator receives a stable internal support reference.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: User Story 2 - Submit A Run-Scoped Support Request (Priority: P1)
|
|
||||||
|
|
||||||
**Goal**: An entitled operator already inspecting one run can request support directly from the canonical operation detail surface.
|
|
||||||
|
|
||||||
**Independent Test**: Seed a failed or degraded run with related context, submit a support request from the operation viewer, and verify the saved request uses the run as primary context without breaking the viewer contract.
|
|
||||||
|
|
||||||
### Tests for User Story 2
|
|
||||||
|
|
||||||
- [x] T011 [P] [US2] Add run-context feature coverage for entitled-tenant resolution, persisted run primary context, persisted severity and optional reproduction/contact fields, reduced-attachment mode, and canonical viewer authorization boundaries in `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestActionTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 2
|
|
||||||
|
|
||||||
- [x] T012 [US2] Add the native Filament operation-viewer support actions in grouped secondary header placement on `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, keeping both `Open support diagnostics` and `Request support` under `More` so they stay secondary to the viewer's primary navigation and utility actions
|
|
||||||
- [x] T013 [US2] Implement run-context request capture through the shared support-request context builder so the request uses the current run plus entitled tenant context only after authorization has resolved safely
|
|
||||||
- [x] T014 [US2] Keep run-context request copy and attachment wording aligned with the existing operation viewer and support-diagnostics vocabulary rather than introducing a second run-summary dialect
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 2 is independently functional when the operation viewer can create the same support-request truth with run-centered context and correct tenant-safe boundaries.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: User Story 3 - Keep Support Intake Redacted, Auditable, And Bounded (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: The same authorized input always produces the same safe context shape and the feature stays free of ticket-provider, lifecycle, and `OperationRun` sprawl.
|
|
||||||
|
|
||||||
**Independent Test**: Verify deterministic context-envelope generation, correct attachment modes, correct audit logging, and absence of outbound HTTP or `OperationRun` side effects.
|
|
||||||
|
|
||||||
### Tests for User Story 3
|
|
||||||
|
|
||||||
- [x] T015 [P] [US3] Add unit coverage for deterministic context-envelope generation, explicit `missing` and inaccessible markers, internal support-reference formatting, and the absence of raw provider payload content in persisted context envelopes in `apps/platform/tests/Unit/Support/SupportRequests/SupportRequestContextBuilderTest.php` and `apps/platform/tests/Unit/Support/SupportRequests/SupportRequestReferenceTest.php`
|
|
||||||
- [x] T016 [P] [US3] Add shared feature coverage for authorization boundaries and attachment-mode behavior in `apps/platform/tests/Feature/SupportRequests/SupportRequestAuthorizationTest.php`
|
|
||||||
- [x] T017 [P] [US3] Add feature coverage for support-request audit entries, duplicate submissions creating two distinct records and support references, and the absence of outbound HTTP or `OperationRun` side effects in `apps/platform/tests/Feature/SupportRequests/SupportRequestAuditTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 3
|
|
||||||
|
|
||||||
- [x] T018 [US3] Finalize immutable persistence rules, explicit omission markers, explicit `missing` and inaccessible related-record markers, explicit exclusion of raw provider payload content from persisted context, and redacted audit payload shape in `apps/platform/app/Models/SupportRequest.php`, `apps/platform/app/Support/SupportRequests/SupportRequestContextBuilder.php`, `apps/platform/app/Support/SupportRequests/SupportRequestReferenceGenerator.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
|
|
||||||
- [x] T019 [US3] Ensure the first slice introduces no edit flow, status workflow, external ticket reference, or support inbox surface while completing the create path on the two approved surfaces only
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 3 is independently functional when support-request creation is deterministic, redacted, audited, and still bounded to the v1 create-only contract.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Polish & Cross-Cutting Concerns
|
|
||||||
|
|
||||||
**Purpose**: Align wording, formatting, and the final validation suite before implementation close-out.
|
|
||||||
|
|
||||||
- [x] T020 [P] Confirm that `Request support`, `Support reference`, attachment-mode copy, omission markers, and redaction notes stay aligned across `apps/platform/app/Support/SupportRequests/`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, and `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
|
||||||
- [x] T021 Run formatting on touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
|
||||||
- [x] T022 Run the focused unit validation suite with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/SupportRequestContextBuilderTest.php tests/Unit/Support/SupportRequests/SupportRequestReferenceTest.php`
|
|
||||||
- [x] T023 Run the focused feature validation suite with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestActionTest.php tests/Feature/SupportRequests/OperationRunSupportRequestActionTest.php tests/Feature/SupportRequests/SupportRequestAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestAuditTest.php`
|
|
||||||
- [x] T024 Record the final guardrail close-out and any bounded `document-in-feature` note for attachment-mode wording or tenant-dashboard exemption handling in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/246-support-request-context/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/246-support-request-context/checklists/requirements.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies & Execution Order
|
|
||||||
|
|
||||||
### Phase Dependencies
|
|
||||||
|
|
||||||
- Phase 1 starts immediately.
|
|
||||||
- Phase 2 depends on Phase 1 and blocks all user stories.
|
|
||||||
- Phase 3 depends on Phase 2 and establishes the MVP tenant-context request flow.
|
|
||||||
- Phase 4 depends on Phase 2 and is safest after Phase 3 because both stories extend the same shared support-request capture path.
|
|
||||||
- Phase 5 depends on Phase 3 and Phase 4 because deterministic attachment, authorization, and audit behavior must cover both approved contexts.
|
|
||||||
- Phase 6 depends on every implemented story.
|
|
||||||
|
|
||||||
### User Story Dependencies
|
|
||||||
|
|
||||||
- US1 is the MVP and first independently shippable increment.
|
|
||||||
- US2 is independently testable but shares the same support-request capture path, so merge order should favor US1 first.
|
|
||||||
- US3 depends on both P1 stories because deterministic context and audit proof must cover the shared request contract across both contexts.
|
|
||||||
|
|
||||||
### Within Each User Story
|
|
||||||
|
|
||||||
- Write the listed Pest coverage first and ensure it fails before implementation.
|
|
||||||
- Complete shared capture-path changes before the final surface wiring pass when both are required.
|
|
||||||
- Re-run the narrowest affected unit or feature suite after each story checkpoint before moving to the next story.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Parallel Opportunities
|
|
||||||
|
|
||||||
### Phase 1
|
|
||||||
|
|
||||||
- T001 and T002 can run in parallel if one person confirms the feature package while another confirms the existing support-aware code seams.
|
|
||||||
|
|
||||||
### Phase 2
|
|
||||||
|
|
||||||
- T004 and T006 can run in parallel after T003 and T005 define the persisted support-request truth and shared capture shape.
|
|
||||||
|
|
||||||
### User Story 1
|
|
||||||
|
|
||||||
- T007 can start before runtime edits.
|
|
||||||
- T008 and T009 can overlap once the shared capture path exists.
|
|
||||||
|
|
||||||
### User Story 2
|
|
||||||
|
|
||||||
- T011 can start before runtime edits.
|
|
||||||
- T012 and T013 can overlap once the shared capture path exists.
|
|
||||||
|
|
||||||
### User Story 3
|
|
||||||
|
|
||||||
- T015, T016, and T017 can run in parallel.
|
|
||||||
- T018 and T019 should stay sequential because both finalize the shared persistence and guardrail boundaries.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Strategy
|
|
||||||
|
|
||||||
### MVP First
|
|
||||||
|
|
||||||
1. Complete Phase 1.
|
|
||||||
2. Complete Phase 2.
|
|
||||||
3. Complete Phase 3 (US1).
|
|
||||||
4. Re-run the tenant-context suite and stop for review.
|
|
||||||
|
|
||||||
### Incremental Delivery
|
|
||||||
|
|
||||||
1. Deliver US1 to compress tenant-context support intake first.
|
|
||||||
2. Add US2 so the same request truth can start from the canonical run detail surface.
|
|
||||||
3. Add US3 to harden deterministic context, authorization, and audit behavior while keeping the slice bounded.
|
|
||||||
|
|
||||||
### Team Strategy
|
|
||||||
|
|
||||||
1. Finish Phase 2 together before splitting work.
|
|
||||||
2. Parallelize test authoring inside each story.
|
|
||||||
3. Sequence merges carefully around `apps/platform/app/Support/SupportRequests/SupportRequestContextBuilder.php`, because every story extends the same shared capture path.
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
# Specification Quality Checklist: Plans, Entitlements & Billing Readiness
|
|
||||||
|
|
||||||
**Purpose**: Validate full preparation-package completeness and implementation readiness after planning and task generation
|
|
||||||
**Created**: 2026-04-27
|
|
||||||
**Feature**: [spec.md](../spec.md)
|
|
||||||
|
|
||||||
## Content Quality
|
|
||||||
|
|
||||||
- [x] Business value and operator outcomes stay explicit
|
|
||||||
- [x] The first slice is bounded to one workspace plan profile, two entitlement keys, two first enforcement points, and one read-only system summary
|
|
||||||
- [x] Runtime-governance sections are present for a future runtime feature, not treated as docs-only
|
|
||||||
- [x] All mandatory sections are completed
|
|
||||||
|
|
||||||
## Requirement Completeness
|
|
||||||
|
|
||||||
- [x] No `[NEEDS CLARIFICATION]` markers remain
|
|
||||||
- [x] Requirements are testable and unambiguous
|
|
||||||
- [x] Acceptance scenarios are defined for the primary user journeys
|
|
||||||
- [x] Edge cases are identified, including over-limit workspaces, existing artifacts, and unchanged queued runs
|
|
||||||
- [x] Scope is clearly bounded away from checkout, invoices, payment providers, proration, trials, grace periods, and a customer-account domain
|
|
||||||
- [x] Dependencies, assumptions, risks, and follow-up candidates are identified
|
|
||||||
|
|
||||||
## Feature Readiness
|
|
||||||
|
|
||||||
- [x] The first slice is small enough for bounded later planning
|
|
||||||
- [x] Concrete repo surfaces are named for settings, onboarding, review-pack generation, and system visibility
|
|
||||||
- [x] Follow-up commercial work is separated from the current slice instead of hidden inside it
|
|
||||||
- [x] No unresolved product question blocks `/speckit.implement` once artifact analysis passes
|
|
||||||
- [x] The selected candidate remains recognizable while the implementation slice stays narrow
|
|
||||||
|
|
||||||
## Governance Readiness
|
|
||||||
|
|
||||||
- [x] Workspace-owned settings are explicitly chosen over a new billing/account persistence model
|
|
||||||
- [x] Capability-first RBAC and 404 versus 403 semantics remain explicit
|
|
||||||
- [x] Entitlement denials are separated from RBAC denials and described as truthful product-state blocks
|
|
||||||
- [x] System-plane visibility is read-only and auditable in the first slice
|
|
||||||
- [x] Operator-facing surfaces include the required UI contract sections and action matrix
|
|
||||||
- [x] Livewire v4 compliance, unchanged provider registration location, no global-search changes, confirmation expectations for destructive actions, and no asset-strategy changes are explicit in the package
|
|
||||||
|
|
||||||
## Test Governance Review
|
|
||||||
|
|
||||||
- [x] Lane fit stays in focused unit plus feature validation only
|
|
||||||
- [x] Fixture and helper growth stays local to workspace, tenant, review-pack, and platform-directory contexts
|
|
||||||
- [x] No browser or heavy-governance family is introduced implicitly
|
|
||||||
- [x] Minimal validation commands are explicit in the spec
|
|
||||||
- [x] Runtime impact is treated as a real future feature, not as a documentation-only update
|
|
||||||
|
|
||||||
## Review Outcome
|
|
||||||
|
|
||||||
- [x] Review outcome class: `keep`
|
|
||||||
- [x] Workflow outcome: `keep`
|
|
||||||
- [x] Next command readiness: `/speckit.implement` after analyze issues are cleared
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- This checklist now validates the full preparation package: spec, plan, supporting design artifacts, and tasks. It does not imply that application code already exists.
|
|
||||||
- The first slice intentionally stops before trial or grace lifecycle state, payment integration, broader plan matrices, or any customer-account domain.
|
|
||||||
- System-plane mutation is deferred on purpose; the first slice keeps system visibility read-only to avoid creating a second commercial source of truth.
|
|
||||||
- Implementation close-out note (2026-04-27): the bounded slice has now been implemented and the focused validation lanes completed. The final post-format review-pack and system-directory lane passed at `31 passed (133 assertions)`.
|
|
||||||
- Browser smoke close-out note (2026-04-27): an integrated-browser smoke attempt was made because the slice changed user-facing Filament surfaces, but the environment could not provide a reliable authenticated tenant/system panel context. The smoke result is therefore classified as environment-blocked rather than pass/fail.
|
|
||||||
- Shared-surface note (2026-04-27): the final proof for blocked review-pack actions relies on the shared operator-facing tooltip helper text plus disabled action state. Direct tooltip-object inspection on the wrapped recordless Filament header action was not stable enough to serve as the final regression check.
|
|
||||||
@ -1,456 +0,0 @@
|
|||||||
openapi: 3.0.3
|
|
||||||
info:
|
|
||||||
title: TenantPilot Admin/System — Workspace Entitlements Foundation (Conceptual)
|
|
||||||
version: 0.1.0
|
|
||||||
description: |
|
|
||||||
Conceptual contract for the workspace-first entitlement foundation.
|
|
||||||
|
|
||||||
NOTE: These routes are implemented as existing Filament pages, widgets,
|
|
||||||
resources, and Livewire-backed actions. The exact Livewire payload shape is
|
|
||||||
not part of this contract. This file captures the user-visible routes,
|
|
||||||
logical action boundaries, and the required 404 / 403 / business-state
|
|
||||||
blocking semantics for the first slice.
|
|
||||||
servers:
|
|
||||||
- url: /admin
|
|
||||||
- url: /system
|
|
||||||
paths:
|
|
||||||
/settings/workspace:
|
|
||||||
get:
|
|
||||||
summary: View workspace entitlement settings
|
|
||||||
description: |
|
|
||||||
Renders the existing workspace settings singleton page with one new
|
|
||||||
entitlement section.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- No workspace selected: redirect to `/admin/choose-workspace`
|
|
||||||
- Non-member or wrong workspace: 404
|
|
||||||
- Workspace member without `workspace_settings.view`: 403
|
|
||||||
- Authorized member: render plan profile, effective entitlements,
|
|
||||||
source labels, rationale, and current usage summary
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Workspace settings page rendered
|
|
||||||
content:
|
|
||||||
text/html:
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
x-logical-view-model:
|
|
||||||
$ref: '#/components/schemas/WorkspaceEntitlementSettingsView'
|
|
||||||
'302':
|
|
||||||
description: Redirect to choose-workspace when no workspace is active
|
|
||||||
'403':
|
|
||||||
$ref: '#/components/responses/Forbidden'
|
|
||||||
'404':
|
|
||||||
$ref: '#/components/responses/NotFound'
|
|
||||||
/settings/workspace/actions/save-entitlements:
|
|
||||||
post:
|
|
||||||
summary: Save plan profile and explicit entitlement overrides
|
|
||||||
description: |
|
|
||||||
Conceptual contract for the existing singleton settings save action.
|
|
||||||
The save reuses existing workspace-setting persistence and audit logging.
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/WorkspaceEntitlementSettingsCommand'
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: Settings saved successfully
|
|
||||||
'403':
|
|
||||||
$ref: '#/components/responses/Forbidden'
|
|
||||||
'404':
|
|
||||||
$ref: '#/components/responses/NotFound'
|
|
||||||
'422':
|
|
||||||
$ref: '#/components/responses/ValidationError'
|
|
||||||
/settings/workspace/actions/reset-entitlement-override/{entitlementKey}:
|
|
||||||
post:
|
|
||||||
summary: Reset one explicit entitlement override and rationale
|
|
||||||
description: |
|
|
||||||
Conceptual contract for a confirmation-protected override reset action.
|
|
||||||
Resetting returns effective truth to the selected plan profile or the
|
|
||||||
code-owned default profile.
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/EntitlementKey'
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: Override reset successfully
|
|
||||||
'403':
|
|
||||||
$ref: '#/components/responses/Forbidden'
|
|
||||||
'404':
|
|
||||||
$ref: '#/components/responses/NotFound'
|
|
||||||
/onboarding/{onboardingDraft}:
|
|
||||||
get:
|
|
||||||
summary: View onboarding workflow with entitlement-aware completion state
|
|
||||||
description: |
|
|
||||||
Renders the existing managed-tenant onboarding wizard. The completion
|
|
||||||
step must include managed-tenant activation entitlement truth.
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/OnboardingDraftId'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Onboarding wizard rendered
|
|
||||||
content:
|
|
||||||
text/html:
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
x-logical-view-model:
|
|
||||||
$ref: '#/components/schemas/OnboardingEntitlementView'
|
|
||||||
'403':
|
|
||||||
$ref: '#/components/responses/Forbidden'
|
|
||||||
'404':
|
|
||||||
$ref: '#/components/responses/NotFound'
|
|
||||||
/onboarding/{onboardingDraft}/actions/complete:
|
|
||||||
post:
|
|
||||||
summary: Complete onboarding when entitlement and existing readiness allow
|
|
||||||
description: |
|
|
||||||
Conceptual contract for the existing confirmation-protected completion
|
|
||||||
action. The entitlement gate must run before any tenant activation
|
|
||||||
mutation occurs.
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/OnboardingDraftId'
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: Onboarding completed
|
|
||||||
'403':
|
|
||||||
$ref: '#/components/responses/Forbidden'
|
|
||||||
'404':
|
|
||||||
$ref: '#/components/responses/NotFound'
|
|
||||||
'409':
|
|
||||||
$ref: '#/components/responses/BusinessStateBlocked'
|
|
||||||
/review-packs/actions/generate:
|
|
||||||
post:
|
|
||||||
summary: Generate a review pack from the current tenant context
|
|
||||||
description: |
|
|
||||||
Conceptual contract for the tenant dashboard widget and review-pack list
|
|
||||||
generate action family. Existing dedupe and queued-start behavior remain
|
|
||||||
unchanged when entitlement allows execution.
|
|
||||||
requestBody:
|
|
||||||
required: false
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ReviewPackGenerationCommand'
|
|
||||||
responses:
|
|
||||||
'202':
|
|
||||||
description: Generation accepted or deduped through the existing flow
|
|
||||||
'403':
|
|
||||||
$ref: '#/components/responses/Forbidden'
|
|
||||||
'404':
|
|
||||||
$ref: '#/components/responses/NotFound'
|
|
||||||
'409':
|
|
||||||
$ref: '#/components/responses/BusinessStateBlocked'
|
|
||||||
/tenant-reviews/{tenantReview}/actions/export-executive-pack:
|
|
||||||
post:
|
|
||||||
summary: Export an executive pack from an existing tenant review
|
|
||||||
description: |
|
|
||||||
Conceptual contract for the review register and tenant review detail
|
|
||||||
export action family. The entitlement gate must run before any new
|
|
||||||
`ReviewPack` or `OperationRun` is created.
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/TenantReviewId'
|
|
||||||
responses:
|
|
||||||
'202':
|
|
||||||
description: Export accepted or deduped through the existing flow
|
|
||||||
'403':
|
|
||||||
$ref: '#/components/responses/Forbidden'
|
|
||||||
'404':
|
|
||||||
$ref: '#/components/responses/NotFound'
|
|
||||||
'409':
|
|
||||||
$ref: '#/components/responses/BusinessStateBlocked'
|
|
||||||
/review-packs/{reviewPack}/actions/regenerate:
|
|
||||||
post:
|
|
||||||
summary: Regenerate an existing review pack
|
|
||||||
description: |
|
|
||||||
Conceptual contract for the existing review-pack detail regenerate
|
|
||||||
action. Existing confirmation and reuse behavior remain in place.
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/ReviewPackId'
|
|
||||||
requestBody:
|
|
||||||
required: false
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ReviewPackGenerationCommand'
|
|
||||||
responses:
|
|
||||||
'202':
|
|
||||||
description: Regeneration accepted or deduped through the existing flow
|
|
||||||
'403':
|
|
||||||
$ref: '#/components/responses/Forbidden'
|
|
||||||
'404':
|
|
||||||
$ref: '#/components/responses/NotFound'
|
|
||||||
'409':
|
|
||||||
$ref: '#/components/responses/BusinessStateBlocked'
|
|
||||||
/directory/workspaces/{workspace}:
|
|
||||||
get:
|
|
||||||
summary: View read-only workspace entitlement summary in the system plane
|
|
||||||
description: |
|
|
||||||
Renders the existing system directory workspace detail page with a
|
|
||||||
read-only entitlement summary.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- Platform user with `platform.directory.view`: 200
|
|
||||||
- Platform user without that capability: 403
|
|
||||||
- Wrong-plane or non-platform actor: 404 semantics at the panel boundary
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/WorkspaceId'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: System workspace detail rendered
|
|
||||||
content:
|
|
||||||
text/html:
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
x-logical-view-model:
|
|
||||||
$ref: '#/components/schemas/SystemWorkspaceEntitlementView'
|
|
||||||
'403':
|
|
||||||
$ref: '#/components/responses/Forbidden'
|
|
||||||
'404':
|
|
||||||
$ref: '#/components/responses/NotFound'
|
|
||||||
components:
|
|
||||||
parameters:
|
|
||||||
WorkspaceId:
|
|
||||||
name: workspace
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
OnboardingDraftId:
|
|
||||||
name: onboardingDraft
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
TenantReviewId:
|
|
||||||
name: tenantReview
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
ReviewPackId:
|
|
||||||
name: reviewPack
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
EntitlementKey:
|
|
||||||
name: entitlementKey
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- managed_tenant_activation_limit
|
|
||||||
- review_pack_generation_enabled
|
|
||||||
responses:
|
|
||||||
Forbidden:
|
|
||||||
description: Member or platform user lacks the required capability in an already established scope
|
|
||||||
NotFound:
|
|
||||||
description: Wrong plane, non-member scope, or inaccessible record
|
|
||||||
BusinessStateBlocked:
|
|
||||||
description: Actor is otherwise authorized, but the workspace is not entitled for the requested action
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/EntitlementBlockResponse'
|
|
||||||
ValidationError:
|
|
||||||
description: Submitted entitlement settings failed validation
|
|
||||||
schemas:
|
|
||||||
WorkspaceEntitlementSettingsCommand:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- plan_profile
|
|
||||||
- entitlements
|
|
||||||
properties:
|
|
||||||
plan_profile:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
description: Null means use the code-owned default profile
|
|
||||||
entitlements:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/EntitlementOverrideInput'
|
|
||||||
EntitlementOverrideInput:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- key
|
|
||||||
properties:
|
|
||||||
key:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- managed_tenant_activation_limit
|
|
||||||
- review_pack_generation_enabled
|
|
||||||
override_value:
|
|
||||||
oneOf:
|
|
||||||
- type: integer
|
|
||||||
- type: boolean
|
|
||||||
nullable: true
|
|
||||||
rationale:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
ReviewPackGenerationCommand:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
include_pii:
|
|
||||||
type: boolean
|
|
||||||
include_operations:
|
|
||||||
type: boolean
|
|
||||||
WorkspaceEntitlementSettingsView:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- workspace_id
|
|
||||||
- effective_plan_profile
|
|
||||||
- entitlements
|
|
||||||
- primary_action
|
|
||||||
properties:
|
|
||||||
workspace_id:
|
|
||||||
type: integer
|
|
||||||
effective_plan_profile:
|
|
||||||
$ref: '#/components/schemas/PlanProfileSummary'
|
|
||||||
entitlements:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/WorkspaceEntitlementDecision'
|
|
||||||
last_changed:
|
|
||||||
$ref: '#/components/schemas/LastChangedAttribution'
|
|
||||||
nullable: true
|
|
||||||
primary_action:
|
|
||||||
$ref: '#/components/schemas/NextAction'
|
|
||||||
OnboardingEntitlementView:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- draft_id
|
|
||||||
- completion_decision
|
|
||||||
properties:
|
|
||||||
draft_id:
|
|
||||||
type: integer
|
|
||||||
completion_decision:
|
|
||||||
$ref: '#/components/schemas/WorkspaceEntitlementDecision'
|
|
||||||
primary_action:
|
|
||||||
$ref: '#/components/schemas/NextAction'
|
|
||||||
blocked_reason:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
SystemWorkspaceEntitlementView:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- workspace_id
|
|
||||||
- effective_plan_profile
|
|
||||||
- entitlements
|
|
||||||
properties:
|
|
||||||
workspace_id:
|
|
||||||
type: integer
|
|
||||||
effective_plan_profile:
|
|
||||||
$ref: '#/components/schemas/PlanProfileSummary'
|
|
||||||
entitlements:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/WorkspaceEntitlementDecision'
|
|
||||||
last_changed:
|
|
||||||
$ref: '#/components/schemas/LastChangedAttribution'
|
|
||||||
nullable: true
|
|
||||||
PlanProfileSummary:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- id
|
|
||||||
- label
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
label:
|
|
||||||
type: string
|
|
||||||
description:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
source:
|
|
||||||
type: string
|
|
||||||
enum: [workspace_selection, code_default]
|
|
||||||
WorkspaceEntitlementDecision:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- key
|
|
||||||
- effective_value
|
|
||||||
- source
|
|
||||||
- is_blocked
|
|
||||||
properties:
|
|
||||||
key:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- managed_tenant_activation_limit
|
|
||||||
- review_pack_generation_enabled
|
|
||||||
effective_value:
|
|
||||||
oneOf:
|
|
||||||
- type: integer
|
|
||||||
- type: boolean
|
|
||||||
source:
|
|
||||||
type: string
|
|
||||||
enum: [plan_profile_default, workspace_override]
|
|
||||||
rationale:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
current_usage:
|
|
||||||
type: integer
|
|
||||||
nullable: true
|
|
||||||
remaining_capacity:
|
|
||||||
type: integer
|
|
||||||
nullable: true
|
|
||||||
is_blocked:
|
|
||||||
type: boolean
|
|
||||||
block_reason:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
LastChangedAttribution:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- at
|
|
||||||
- by
|
|
||||||
properties:
|
|
||||||
at:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
by:
|
|
||||||
type: string
|
|
||||||
EntitlementBlockResponse:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- key
|
|
||||||
- reason
|
|
||||||
properties:
|
|
||||||
key:
|
|
||||||
type: string
|
|
||||||
reason:
|
|
||||||
type: string
|
|
||||||
source:
|
|
||||||
type: string
|
|
||||||
enum: [plan_profile_default, workspace_override]
|
|
||||||
current_usage:
|
|
||||||
type: integer
|
|
||||||
nullable: true
|
|
||||||
effective_value:
|
|
||||||
oneOf:
|
|
||||||
- type: integer
|
|
||||||
- type: boolean
|
|
||||||
NextAction:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- label
|
|
||||||
- kind
|
|
||||||
properties:
|
|
||||||
label:
|
|
||||||
type: string
|
|
||||||
kind:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- save_entitlements
|
|
||||||
- reset_override
|
|
||||||
- complete_onboarding
|
|
||||||
- generate_pack
|
|
||||||
- export_executive_pack
|
|
||||||
- regenerate_pack
|
|
||||||
- open_admin_workspace
|
|
||||||
action_name:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
url:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
# Data Model: Plans, Entitlements & Billing Readiness
|
|
||||||
|
|
||||||
**Date**: 2026-04-27
|
|
||||||
**Branch**: `247-plans-entitlements-billing-readiness`
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This slice adds no new table. Persisted truth stays in existing `workspace_settings` rows, while plan defaults and effective entitlement decisions remain derived.
|
|
||||||
|
|
||||||
## Persisted Truth
|
|
||||||
|
|
||||||
### 1. Workspace Entitlement Settings Aggregate
|
|
||||||
|
|
||||||
**Persistence**: Existing `App\Models\WorkspaceSetting` rows
|
|
||||||
**Ownership**: Workspace-owned
|
|
||||||
**Scope**: One workspace, no tenant-owned persistence, no system-plane mutation
|
|
||||||
|
|
||||||
The slice reuses explicit settings keys under an `entitlements` domain.
|
|
||||||
|
|
||||||
| Setting key | Type | Nullable | Validation | Notes |
|
|
||||||
|-------------|------|----------|------------|-------|
|
|
||||||
| `entitlements.plan_profile` | string | yes | must match a code-owned plan-profile identifier when present | `null` means use the code-owned default profile |
|
|
||||||
| `entitlements.managed_tenant_limit_override_value` | int | yes | integer, `>= 0` | Explicit override for onboarding activation limit |
|
|
||||||
| `entitlements.managed_tenant_limit_override_reason` | string | yes | required when the paired override value is present; trimmed; max 500 chars | Operator-entered rationale shown on admin and system surfaces |
|
|
||||||
| `entitlements.review_pack_generation_override_value` | bool | yes | boolean | Explicit override for whether new `Generate pack`, `Regenerate`, and `Export executive pack` actions are allowed |
|
|
||||||
| `entitlements.review_pack_generation_override_reason` | string | yes | required when the paired override value is present; trimmed; max 500 chars | Operator-entered rationale shown on admin and system surfaces |
|
|
||||||
|
|
||||||
**Write rules**:
|
|
||||||
|
|
||||||
- Saving the section may update several `WorkspaceSetting` rows in one page submission, but each row continues to use the existing `SettingsWriter` audit path.
|
|
||||||
- Resetting an override clears both the override value and its rationale, returning effective truth to the selected plan profile or code-owned default profile.
|
|
||||||
- Lowering the managed-tenant limit below current usage does not mutate tenant records; it only changes future activation eligibility.
|
|
||||||
|
|
||||||
**Relationships**:
|
|
||||||
|
|
||||||
- `workspace_settings.workspace_id` anchors all persisted truth to a workspace.
|
|
||||||
- `workspace_settings.updated_by_user_id` remains the attribution source for last change metadata.
|
|
||||||
|
|
||||||
## Code-Owned Truth
|
|
||||||
|
|
||||||
### 2. Workspace Plan Profile Catalog Entry
|
|
||||||
|
|
||||||
**Persistence**: none, code-owned
|
|
||||||
**Ownership**: Product/runtime configuration
|
|
||||||
**Scope**: first-slice only
|
|
||||||
|
|
||||||
| Field | Type | Required | Notes |
|
|
||||||
|-------|------|----------|-------|
|
|
||||||
| `id` | string | yes | Stable internal identifier stored in `entitlements.plan_profile` |
|
|
||||||
| `label` | string | yes | Operator-facing plan profile label on settings and system surfaces |
|
|
||||||
| `description` | string | yes | Concise explanation of what the profile allows |
|
|
||||||
| `managed_tenant_limit_default` | int | yes | Default active managed-tenant activation limit |
|
|
||||||
| `review_pack_generation_default` | bool | yes | Default allow/block state for new review-pack generation |
|
|
||||||
| `is_default` | bool | yes | Exactly one profile is the code-owned fallback when no workspace setting exists |
|
|
||||||
|
|
||||||
**Rules**:
|
|
||||||
|
|
||||||
- The catalog is intentionally bounded to the first slice and must not grow into a broader entitlement matrix in this feature.
|
|
||||||
- The catalog is not operator-editable and is not a contract, invoice, or subscription record.
|
|
||||||
|
|
||||||
## Derived Truth
|
|
||||||
|
|
||||||
### 3. Effective Workspace Entitlement Decision
|
|
||||||
|
|
||||||
**Persistence**: none, derived at runtime
|
|
||||||
**Owner**: bounded `WorkspaceEntitlementResolver`
|
|
||||||
|
|
||||||
| Field | Type | Required | Notes |
|
|
||||||
|-------|------|----------|-------|
|
|
||||||
| `workspace_id` | int | yes | Workspace being evaluated |
|
|
||||||
| `plan_profile_id` | string | yes | Effective profile after applying the code-owned default fallback |
|
|
||||||
| `key` | string | yes | One of the two first-slice entitlement keys |
|
|
||||||
| `effective_value` | int or bool | yes | Final value after plan defaults plus any workspace override |
|
|
||||||
| `source` | string | yes | `plan_profile_default` or `workspace_override` |
|
|
||||||
| `rationale` | string | no | Override reason when source is `workspace_override`; otherwise optional plan-profile description |
|
|
||||||
| `current_usage` | int | no | Active managed-tenant count for the limit-based key; `null` for the boolean key |
|
|
||||||
| `remaining_capacity` | int | no | Derived only for the limit-based key |
|
|
||||||
| `is_blocked` | bool | yes | Whether the current action should stop for business-state reasons |
|
|
||||||
| `block_reason` | string | no | Operator-facing explanation used on onboarding and review-pack surfaces when blocked |
|
|
||||||
| `last_changed_at` | datetime | no | Derived from the most recent entitlement-related `WorkspaceSetting` row if present |
|
|
||||||
| `last_changed_by` | string | no | Derived actor attribution for settings and system visibility |
|
|
||||||
|
|
||||||
**Key catalog**:
|
|
||||||
|
|
||||||
| Entitlement key | Value type | Used by |
|
|
||||||
|-----------------|------------|---------|
|
|
||||||
| `managed_tenant_activation_limit` | int | `ManagedTenantOnboardingWizard` completion eligibility and summary |
|
|
||||||
| `review_pack_generation_enabled` | bool | `ReviewPackService`, tenant dashboard widget, review register, tenant review view, review-pack list/detail actions |
|
|
||||||
|
|
||||||
**Behavior rules**:
|
|
||||||
|
|
||||||
- `managed_tenant_activation_limit` compares `current_usage` to the effective limit and blocks only future onboarding activation.
|
|
||||||
- `review_pack_generation_enabled=false` blocks new generate, regenerate, and executive-pack export attempts before `ReviewPack` or `OperationRun` creation.
|
|
||||||
- Existing review-pack downloads and already-generated artifacts remain outside this entitlement decision.
|
|
||||||
|
|
||||||
## Supporting Derived View Models
|
|
||||||
|
|
||||||
### 4. Workspace Entitlement Section Read Model
|
|
||||||
|
|
||||||
**Persistence**: none
|
|
||||||
**Consumer**: `App\Filament\Pages\Settings\WorkspaceSettings`
|
|
||||||
|
|
||||||
Contains:
|
|
||||||
|
|
||||||
- effective plan profile label and description
|
|
||||||
- both entitlement decisions
|
|
||||||
- editable override values plus rationale inputs
|
|
||||||
- current managed-tenant usage summary
|
|
||||||
- last changed attribution for the `entitlements` domain
|
|
||||||
|
|
||||||
### 5. System Workspace Entitlement Summary Read Model
|
|
||||||
|
|
||||||
**Persistence**: none
|
|
||||||
**Consumer**: `App\Filament\System\Pages\Directory\ViewWorkspace` and `resources/views/filament/system/pages/directory/view-workspace.blade.php`
|
|
||||||
|
|
||||||
Contains:
|
|
||||||
|
|
||||||
- read-only effective plan profile label
|
|
||||||
- both entitlement decisions with source and rationale
|
|
||||||
- last changed attribution
|
|
||||||
- current managed-tenant usage summary
|
|
||||||
|
|
||||||
## Derived Query Dependencies
|
|
||||||
|
|
||||||
| Need | Source | Notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| Active managed-tenant usage | existing tenant/workspace runtime truth | Count active managed tenants for the current workspace only; no persisted counter needed |
|
|
||||||
| Last change attribution | existing `workspace_settings.updated_by_user_id` and timestamps | Derived from entitlement-related settings rows only |
|
|
||||||
| Review-pack run creation proof | existing `review_packs` and `operation_runs` behavior | Used only in tests to prove blocked attempts create no new run |
|
|
||||||
|
|
||||||
## State Transitions
|
|
||||||
|
|
||||||
No new persisted lifecycle state is introduced.
|
|
||||||
|
|
||||||
Derived runtime states for the limit-based entitlement:
|
|
||||||
|
|
||||||
| State | Trigger | Consequence |
|
|
||||||
|-------|---------|-------------|
|
|
||||||
| `within_limit` | `current_usage < effective_value` | Onboarding completion may proceed if all other existing checks pass |
|
|
||||||
| `at_limit` | `current_usage >= effective_value` | Future onboarding completion is blocked with a truthful reason |
|
|
||||||
| `over_limit_after_lowering` | Workspace limit is lowered below current usage | Existing tenants stay active; future onboarding completion remains blocked until usage or limit changes |
|
|
||||||
|
|
||||||
Derived runtime states for the review-pack entitlement:
|
|
||||||
|
|
||||||
| State | Trigger | Consequence |
|
|
||||||
|-------|---------|-------------|
|
|
||||||
| `enabled` | effective boolean value is `true` | Existing review-pack start flow proceeds unchanged |
|
|
||||||
| `disabled` | effective boolean value is `false` | New generate/regenerate/export attempts block before run creation |
|
|
||||||
@ -1,275 +0,0 @@
|
|||||||
# Implementation Plan: Plans, Entitlements & Billing Readiness
|
|
||||||
|
|
||||||
**Branch**: `247-plans-entitlements-billing-readiness` | **Date**: 2026-04-27 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/spec.md`
|
|
||||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/spec.md`
|
|
||||||
|
|
||||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- Extend the existing workspace settings foundation with one bounded workspace entitlement slice: one code-owned plan-profile catalog, two first-slice entitlement keys, explicit workspace override values plus rationale, and one derived decision path that surfaces effective value, source, rationale, and usage truth.
|
|
||||||
- Reuse `WorkspaceSetting`, `SettingsResolver`, `SettingsWriter`, `WorkspaceSettings`, the capability registries, `ManagedTenantOnboardingWizard`, `ReviewPackService`, the current review-pack Filament entry points, and the system directory workspace detail page rather than introducing a billing/account domain.
|
|
||||||
- Hard enforcement remains narrow: block onboarding activation before tenant lifecycle mutation and block review-pack generation before `ReviewPack` or `OperationRun` creation, while preserving existing queued-start UX when entitlement allows execution.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
|
||||||
**Primary Dependencies**: Filament v5 + Livewire v4 + Laravel 12, existing workspace settings stack, `ReviewPackService`, capability registries
|
|
||||||
**Storage**: PostgreSQL via existing `workspace_settings` persistence; no new table or billing/account model
|
|
||||||
**Testing**: Pest feature and unit tests via Laravel Sail
|
|
||||||
**Validation Lanes**: fast-feedback, confidence
|
|
||||||
**Target Platform**: Monorepo Laravel web application in `apps/platform`, using Filament admin and system panels
|
|
||||||
**Project Type**: web
|
|
||||||
**Performance Goals**: Resolve entitlement truth from existing settings plus one scoped usage aggregate; perform no new external calls during page render; preserve existing review-pack dedupe and queued-start behavior when allowed
|
|
||||||
**Constraints**: Keep scope to one workspace plan profile, two entitlement keys, explicit workspace overrides with rationale, read-only system visibility, 404 for non-members/wrong plane, 403 for members missing capability, truthful business-state block for otherwise authorized actors, and no checkout/invoice/provider/subscription lifecycle work
|
|
||||||
**Scale/Scope**: One new bounded entitlement resolver, one entitlement section on the existing workspace settings page, one onboarding completion gate, one review-pack action family gate, one read-only system summary, and focused Sail/Pest coverage
|
|
||||||
|
|
||||||
## Filament v5 / Panel Notes
|
|
||||||
|
|
||||||
- **Livewire v4.0+ compliance**: The slice stays inside existing Filament v5 pages, widgets, resources, and Livewire-backed actions. No Livewire v3 assumptions or compatibility work are introduced.
|
|
||||||
- **Provider registration location**: No panel/provider registration changes are planned. Existing Laravel 12 + Filament provider registration remains in `bootstrap/providers.php`.
|
|
||||||
- **Global search**: No new globally searchable resource is introduced. Touched existing resources already have dedicated view pages where applicable, and current global-search behavior remains unchanged.
|
|
||||||
- **Destructive and high-impact actions**: Existing onboarding draft cancellation/deletion remain `->requiresConfirmation()` plus capability enforcement. Any new override-reset action must also require confirmation because it can change runtime access. Entitlement denials themselves are non-destructive business-state blocks, not hidden RBAC failures.
|
|
||||||
- **Asset strategy**: No new panel or shared assets are planned. Deployment remains unchanged, including `cd apps/platform && php artisan filament:assets` when registered Filament assets are deployed.
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Plan
|
|
||||||
|
|
||||||
- **Guardrail scope**: changed surfaces
|
|
||||||
- **Native vs custom classification summary**: mixed
|
|
||||||
- **Shared-family relevance**: workspace settings, action gating/helper text, review-pack queued-start UX, read-only system diagnostics
|
|
||||||
- **State layers in scope**: page, detail
|
|
||||||
- **Audience modes in scope**: operator-MSP, support-platform
|
|
||||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first on workspace settings, onboarding completion, and review-pack entry points; diagnostics-second on the read-only system page; no raw payload disclosure in the first slice
|
|
||||||
- **Raw/support gating plan**: capability-gated system-plane diagnostics only; no new raw/support payload section on admin surfaces
|
|
||||||
- **One-primary-action / duplicate-truth control**: workspace settings remains the only mutation surface for commercial posture; onboarding and review-pack surfaces show only the decision truth needed for the current action; the system directory mirrors resolved truth read-only
|
|
||||||
- **Handling modes by drift class or surface**: review-mandatory for shared action-family gating and cross-plane wording consistency
|
|
||||||
- **Repository-signal treatment**: review-mandatory because the slice spans admin and system planes plus an OperationRun-starting workflow family
|
|
||||||
- **Special surface test profiles**: standard-native-filament, shared-detail-family, monitoring-state-page
|
|
||||||
- **Required tests or manual smoke**: functional-core, state-contract
|
|
||||||
- **Exception path and spread control**: none planned; all first-slice surfaces must consume the same resolved decision object or a thin projection from it
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
|
|
||||||
## Shared Pattern & System Fit
|
|
||||||
|
|
||||||
- **Cross-cutting feature marker**: yes
|
|
||||||
- **Systems touched**: `WorkspaceSetting`, `SettingsRegistry`, `SettingsResolver`, `SettingsWriter`, workspace settings audit logging, `ManagedTenantOnboardingWizard`, `ReviewPackService`, current review-pack action surfaces, `OperationUxPresenter`, `OperationRunLinks`, `Capabilities`, `PlatformCapabilities`, and the system directory workspace detail page
|
|
||||||
- **Shared abstractions reused**: `SettingsResolver`, `SettingsWriter`, `App\Services\Audit\WorkspaceAuditLogger`, `App\Support\Auth\Capabilities`, `App\Support\Auth\PlatformCapabilities`, `App\Support\Rbac\UiEnforcement`, `ReviewPackService`, `OperationUxPresenter`, `OperationRunLinks`
|
|
||||||
- **New abstraction introduced? why?**: one bounded `WorkspaceEntitlementResolver` is justified because existing settings helpers resolve individual keys but do not provide plan-profile defaults, override rationale, usage context, or action-ready allow/block truth across multiple surfaces
|
|
||||||
- **Why the existing abstraction was sufficient or insufficient**: existing settings infrastructure is sufficient for persistence, validation, and audit; it is insufficient for multi-surface commercial decision truth because the same effective result must drive settings readback, onboarding activation gating, review-pack gating, and system diagnostics
|
|
||||||
- **Bounded deviation / spread control**: hard enforcement for review packs belongs in `ReviewPackService` and onboarding activation belongs in the existing wizard action; UI surfaces may project the same decision but must not create page-local entitlement rules
|
|
||||||
|
|
||||||
## OperationRun UX Impact
|
|
||||||
|
|
||||||
- **Touches OperationRun start/completion/link UX?**: yes
|
|
||||||
- **Central contract reused**: existing shared review-pack OperationRun start UX through `ReviewPackService`, `OperationUxPresenter`, and `OperationRunLinks`
|
|
||||||
- **Delegated UX behaviors**: queued toast, run link, run-enqueued browser event, dedupe messaging, blocked-before-start behavior, tenant/workspace-safe URL resolution, and existing terminal notifications remain on the current shared path when entitlement allows generation
|
|
||||||
- **Surface-owned behavior kept local**: workspace settings helper text, onboarding completion blocked explanation, and review-pack helper text/disabled state remain local projections of the resolved decision
|
|
||||||
- **Queued DB-notification policy**: unchanged explicit opt-in only; blocked attempts create no run and therefore no queued notification
|
|
||||||
- **Terminal notification path**: unchanged central lifecycle mechanism for existing review-pack runs only
|
|
||||||
- **Exception path**: none
|
|
||||||
|
|
||||||
## Provider Boundary & Portability Fit
|
|
||||||
|
|
||||||
- **Shared provider/platform boundary touched?**: no
|
|
||||||
- **Provider-owned seams**: N/A
|
|
||||||
- **Platform-core seams**: workspace commercial vocabulary, plan profile labels, entitlement source labels, override rationale, read-only support visibility
|
|
||||||
- **Neutral platform terms / contracts preserved**: `workspace`, `plan profile`, `managed tenant limit`, `review pack generation`, `override reason`, `source`
|
|
||||||
- **Retained provider-specific semantics and why**: none; review-pack generation is provider-backed operationally, but the new entitlement vocabulary remains platform-core and provider-neutral
|
|
||||||
- **Bounded extraction or follow-up path**: none
|
|
||||||
|
|
||||||
## Constitution Check
|
|
||||||
|
|
||||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
|
||||||
|
|
||||||
- Inventory-first: PASS - this slice adds workspace-owned product truth, not new inventory or snapshot semantics.
|
|
||||||
- Read/write separation: PASS - settings writes stay inside the existing audited settings path; onboarding and review-pack enforcement block before mutation or run creation; high-impact reset actions remain confirmation-protected.
|
|
||||||
- Graph contract path: PASS - no new Graph calls are introduced; entitlement evaluation is local to settings, workspace counts, and existing review-pack start logic.
|
|
||||||
- Deterministic capabilities: PASS - capability checks remain registry-backed through `Capabilities` and `PlatformCapabilities`.
|
|
||||||
- RBAC-UX: PASS - `/admin` and `/system` stay separated; wrong-plane and non-member access remain 404; member-without-capability remains 403; no raw capability strings are introduced.
|
|
||||||
- Workspace isolation: PASS - workspace membership and workspace context remain required for admin-plane surfaces.
|
|
||||||
- RBAC-UX destructive confirmation: PASS - existing onboarding destructive actions already confirm, and any new override-reset action must also use `->requiresConfirmation()`.
|
|
||||||
- RBAC-UX global search: PASS - no new searchable resource or search scope is added.
|
|
||||||
- Tenant isolation: PASS - onboarding and review-pack surfaces remain tenant-safe; no cross-tenant leakage is introduced.
|
|
||||||
- Run observability: PASS - review-pack generation keeps the existing `OperationRun` path when allowed, and blocked attempts stop before `OperationRun` creation.
|
|
||||||
- OperationRun start UX: PASS - shared review-pack start UX is preserved; no local queued-toast composition is planned.
|
|
||||||
- Ops-UX 3-surface feedback: PASS - existing review-pack feedback stays toast + progress surfaces + terminal notification only when a run exists.
|
|
||||||
- Ops-UX lifecycle: PASS - no new `OperationRun` transitions are introduced.
|
|
||||||
- Ops-UX summary counts: N/A - no summary-count shape change is planned.
|
|
||||||
- Ops-UX guards: N/A - no new OperationRun guard rule is required for the planning slice.
|
|
||||||
- Ops-UX system runs: N/A - no initiator-null behavior is touched.
|
|
||||||
- Automation: N/A - no new queued or scheduled workflow family is introduced.
|
|
||||||
- Data minimization: PASS - no secrets, billing payloads, or provider credentials are persisted for entitlements.
|
|
||||||
- Test governance (TEST-GOV-001): PASS - the plan stays in focused unit plus feature lanes with explicit commands and local fixtures only.
|
|
||||||
- Proportionality (PROP-001): PASS - persistence stays in existing workspace settings; only one bounded resolver is added for multi-surface truth.
|
|
||||||
- No premature abstraction (ABSTR-001): PASS - no new registry, interface, or framework is planned; the profile catalog remains plain code-owned data.
|
|
||||||
- Persisted truth (PERSIST-001): PASS - no new table or durable artifact is introduced; all new truth stays in existing `workspace_settings` rows.
|
|
||||||
- Behavioral state (STATE-001): PASS - over-limit and blocked states remain derived behavior, not a new persisted lifecycle model.
|
|
||||||
- UI semantics (UI-SEM-001): PASS - the design prefers direct mapping from resolved decision truth to UI helper text or summary rows.
|
|
||||||
- Shared pattern first (XCUT-001): PASS - the design reuses existing settings, audit, review-pack, and capability paths first.
|
|
||||||
- Provider boundary (PROV-001): PASS - the entitlement vocabulary remains platform-core and provider-neutral.
|
|
||||||
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): PASS - the narrow shape is explicit settings keys plus one resolver and thin UI projections.
|
|
||||||
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): PASS - the only added structural element is one resolver, justified below.
|
|
||||||
- Badge semantics (BADGE-001): PASS - if source or availability is badged later, implementation must reuse existing badge infrastructure or stay text-only; no page-local badge taxonomy is planned.
|
|
||||||
- Filament-native UI (UI-FIL-001): PASS - the slice extends existing Filament pages, widgets, resources, and the existing system detail view.
|
|
||||||
- Filament-native UI local Blade/Tailwind: PASS - the only custom view touch remains the current system directory Blade view, which must preserve existing Filament visual language.
|
|
||||||
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): PASS - existing singleton settings, guided workflow, action family, and read-only detail surface types remain intact.
|
|
||||||
- Decision-first operating model (DECIDE-001): PASS - workspace settings remains primary, onboarding and review packs stay contextual decision points, and the system page remains tertiary diagnostics.
|
|
||||||
- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): PASS - settings and action surfaces stay operator-first, while the system page is support-platform and read-only.
|
|
||||||
- UI/UX inspect model (UI-HARD-001): PASS - no duplicate inspect affordances are added.
|
|
||||||
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): PASS - no new parallel action hierarchy is introduced; current action families remain primary where already present.
|
|
||||||
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): PASS - product-facing labels remain narrow and non-billing.
|
|
||||||
- UI/UX placeholder ban (UI-HARD-001): PASS - no placeholder action groups are planned.
|
|
||||||
- UI naming (UI-NAMING-001): PASS - labels remain `Plan profile`, `Managed tenant limit`, `Review pack generation`, and `Override reason`.
|
|
||||||
- Operator surfaces (OPSURF-001): PASS - mutation scope is explicit and system-plane visibility remains read-only.
|
|
||||||
- Operator surface page contract: PASS - the spec already defines the required page and action contracts.
|
|
||||||
- Filament UI Action Surface Contract: PASS - touched surfaces already have action contracts or exemptions; the plan preserves them while adding entitlement truth.
|
|
||||||
- Filament UI UX-001 (Layout & IA): PASS - no new page shell or alternate layout is planned.
|
|
||||||
- Action-surface discipline (ACTSURF-001 / HDR-001): PASS - workspace settings remains the primary configuration surface; review-pack generation remains the primary reporting action where already present.
|
|
||||||
- UI review workflow: PASS - guardrail, shared-family, and exception posture remain explicit in this plan.
|
|
||||||
|
|
||||||
## Test Governance Check
|
|
||||||
|
|
||||||
- **Test purpose / classification by changed surface**: `Unit` for the bounded resolver and profile defaults; `Feature` for workspace settings, onboarding, review-pack entry surfaces, and the system directory page
|
|
||||||
- **Affected validation lanes**: `fast-feedback`, `confidence`
|
|
||||||
- **Why this lane mix is the narrowest sufficient proof**: the business truth is a deterministic resolver plus existing Filament/Livewire action paths; browser and heavy-governance coverage would add cost without proving extra risk for this bounded slice
|
|
||||||
- **Narrowest proving command(s)**:
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceEntitlementResolverTest.php tests/Unit/Entitlements/WorkspacePlanProfileCatalogTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPacks/ReviewPackEntitlementEnforcementTest.php tests/Feature/System/Directory/ViewWorkspaceEntitlementsTest.php`
|
|
||||||
- **Fixture / helper / factory / seed / context cost risks**: local workspace, workspace membership, active managed-tenant count, tenant review/review-pack context, and platform-user fixtures only
|
|
||||||
- **Expensive defaults or shared helper growth introduced?**: no - implementation should reuse existing factories and scope helpers with opt-in entitlement fixtures
|
|
||||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
|
||||||
- **Surface-class relief / special coverage rule**: standard-native relief for workspace settings and onboarding; shared-detail-family coverage for review-pack entry points; one read-only system detail assertion for the system plane
|
|
||||||
- **Closing validation and reviewer handoff**: rerun the exact targeted Sail/Pest commands above and verify 404/403/business-state semantics separately, verify blocked review-pack attempts create no `OperationRun`, and verify lowered limits do not mutate existing tenants
|
|
||||||
- **Budget / baseline / trend follow-up**: none expected beyond normal feature-local growth
|
|
||||||
- **Review-stop questions**: lane fit, hidden fixture cost, service-level bypass risk on review-pack generation, and cross-plane wording drift
|
|
||||||
- **Escalation path**: none
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Why no dedicated follow-up spec is needed**: the testing cost stays local to one resolver and four existing surface families; no new heavy family or platform-wide harness is introduced
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Documentation (this feature)
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/247-plans-entitlements-billing-readiness/
|
|
||||||
├── plan.md
|
|
||||||
├── research.md
|
|
||||||
├── data-model.md
|
|
||||||
├── quickstart.md
|
|
||||||
├── contracts/
|
|
||||||
│ └── workspace-entitlements-foundation.logical.openapi.yaml
|
|
||||||
├── checklists/
|
|
||||||
│ └── requirements.md
|
|
||||||
└── tasks.md # Created later by /speckit.tasks, not by this plan step
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source Code (repository root)
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/platform/
|
|
||||||
├── app/
|
|
||||||
│ ├── Filament/
|
|
||||||
│ │ ├── Pages/
|
|
||||||
│ │ │ ├── Reviews/ReviewRegister.php
|
|
||||||
│ │ │ ├── Settings/WorkspaceSettings.php
|
|
||||||
│ │ │ └── Workspaces/ManagedTenantOnboardingWizard.php
|
|
||||||
│ │ ├── Resources/
|
|
||||||
│ │ │ ├── ReviewPackResource.php
|
|
||||||
│ │ │ ├── ReviewPackResource/Pages/ListReviewPacks.php
|
|
||||||
│ │ │ ├── ReviewPackResource/Pages/ViewReviewPack.php
|
|
||||||
│ │ │ ├── TenantReviewResource.php
|
|
||||||
│ │ │ └── TenantReviewResource/Pages/ViewTenantReview.php
|
|
||||||
│ │ ├── System/Pages/Directory/ViewWorkspace.php
|
|
||||||
│ │ └── Widgets/Tenant/TenantReviewPackCard.php
|
|
||||||
│ ├── Models/WorkspaceSetting.php
|
|
||||||
│ ├── Services/
|
|
||||||
│ │ ├── ReviewPackService.php
|
|
||||||
│ │ ├── Settings/SettingsResolver.php
|
|
||||||
│ │ ├── Settings/SettingsWriter.php
|
|
||||||
│ │ └── Entitlements/WorkspaceEntitlementResolver.php # likely new bounded service
|
|
||||||
│ ├── Support/
|
|
||||||
│ │ ├── Auth/Capabilities.php
|
|
||||||
│ │ ├── Auth/PlatformCapabilities.php
|
|
||||||
│ │ └── Settings/SettingsRegistry.php
|
|
||||||
├── tests/
|
|
||||||
│ ├── Feature/
|
|
||||||
│ └── Unit/
|
|
||||||
└── resources/views/filament/system/pages/directory/view-workspace.blade.php
|
|
||||||
```
|
|
||||||
|
|
||||||
**Structure Decision**: Single Laravel/Filament application inside `apps/platform`, with one new bounded entitlement resolver and changes limited to existing settings, onboarding, review-pack, and system-directory surfaces plus focused Pest coverage.
|
|
||||||
|
|
||||||
## Complexity Tracking
|
|
||||||
|
|
||||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
|
||||||
|-----------|------------|-------------------------------------|
|
|
||||||
| New bounded entitlement resolver | Multiple existing surfaces need the same effective plan-default versus override decision, source attribution, rationale, and usage truth | Surface-local checks in `WorkspaceSettings`, `ManagedTenantOnboardingWizard`, and the review-pack action family would drift immediately and duplicate business-state wording |
|
|
||||||
|
|
||||||
## Proportionality Review
|
|
||||||
|
|
||||||
- **Current operator problem**: The product cannot currently answer, in one auditable path, whether a workspace may activate another managed tenant or generate a review pack, nor can it show why a manual override exists.
|
|
||||||
- **Existing structure is insufficient because**: raw settings rows and direct capability checks do not produce plan-profile defaults, source attribution, override rationale, usage context, or reusable allow/block truth for multiple surfaces.
|
|
||||||
- **Narrowest correct implementation**: persist only workspace-selected plan profile and explicit override values plus rationale through existing `WorkspaceSetting` rows, keep plan defaults code-owned, and add one bounded resolver that derives effective decisions for the four affected surface families.
|
|
||||||
- **Ownership cost created**: one small default catalog plus one resolver require focused unit tests and wording discipline across settings, onboarding, review-pack, and system visibility.
|
|
||||||
- **Alternative intentionally rejected**: a new `Plan`, `Subscription`, `CustomerAccount`, or broad entitlement matrix domain was rejected because the spec only needs workspace-owned current-release truth for two entitlement keys.
|
|
||||||
- **Release truth**: current-release truth
|
|
||||||
|
|
||||||
## Phase 0 — Research (output: `research.md`)
|
|
||||||
|
|
||||||
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/research.md`
|
|
||||||
|
|
||||||
Goals:
|
|
||||||
- Confirm the narrowest reuse of the existing workspace settings stack for plan profile and override persistence.
|
|
||||||
- Confirm the exact service and page-level enforcement points that prevent onboarding activation or review-pack run creation before mutation.
|
|
||||||
- Confirm how to preserve existing review-pack OperationRun UX while inserting entitlement checks ahead of run creation.
|
|
||||||
- Confirm system/admin plane separation and read-only directory visibility requirements for support users.
|
|
||||||
|
|
||||||
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
|
|
||||||
|
|
||||||
See:
|
|
||||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/data-model.md`
|
|
||||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/contracts/workspace-entitlements-foundation.logical.openapi.yaml`
|
|
||||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md`
|
|
||||||
|
|
||||||
Design focus:
|
|
||||||
- Represent workspace commercial truth with explicit `WorkspaceSetting` keys under an `entitlements` domain rather than a new model family.
|
|
||||||
- Keep the plan-profile catalog code-owned and small, and keep the new logic surface to one bounded `WorkspaceEntitlementResolver` rather than a registry or framework.
|
|
||||||
- Extend `WorkspaceSettings` with one entitlement section that edits plan profile and override/rationale pairs using existing settings write and reset patterns.
|
|
||||||
- Use the same decision path in `ManagedTenantOnboardingWizard` completion state and in review-pack generation entry surfaces, with hard enforcement centralized in `completeOnboarding()` and `ReviewPackService` before any mutation or run creation occurs.
|
|
||||||
- Extend `App\Filament\System\Pages\Directory\ViewWorkspace` plus its Blade view with a read-only entitlement summary instead of adding a second mutation plane.
|
|
||||||
|
|
||||||
## Phase 1 — Agent Context Update
|
|
||||||
|
|
||||||
After Phase 1 artifacts are generated, update Copilot context from the completed plan:
|
|
||||||
|
|
||||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot`
|
|
||||||
|
|
||||||
## Phase 2 — Implementation Outline (tasks created later by `/speckit.tasks`)
|
|
||||||
|
|
||||||
- Add bounded entitlement settings definitions and the plan-profile default map without creating new persistence tables.
|
|
||||||
- Implement `WorkspaceEntitlementResolver` to merge code-owned plan defaults, workspace overrides, override rationale, and current usage for the managed-tenant limit.
|
|
||||||
- Extend `WorkspaceSettings` with a plan profile selector, two override controls, rationale inputs, resolved-source helper text, and confirmed reset actions.
|
|
||||||
- Gate onboarding completion in `ManagedTenantOnboardingWizard` using the shared entitlement decision and preserve existing confirmation plus audit semantics.
|
|
||||||
- Gate every current review-pack generate/regenerate/export entry point through the shared decision, with service-level enforcement in `ReviewPackService` to prevent bypass and preserve existing OperationRun UX when allowed.
|
|
||||||
- Add a read-only entitlement summary to the system directory workspace detail page and preserve system-plane-only visibility.
|
|
||||||
- Add focused Sail/Pest unit and feature coverage for resolver behavior, settings save/reset, onboarding blocking, review-pack no-run blocking, and system visibility.
|
|
||||||
|
|
||||||
## Constitution Check (Post-Design)
|
|
||||||
|
|
||||||
Re-check result: PASS. The design keeps persistence inside existing workspace settings, adds only one bounded resolver, preserves Filament v5 + Livewire v4 surfaces, keeps panel provider registration unchanged in `bootstrap/providers.php`, leaves global search and asset strategy unchanged, enforces 404/403 semantics separately from business-state blocks, and preserves existing review-pack `OperationRun` UX by gating before run creation instead of replacing shared run infrastructure.
|
|
||||||
|
|
||||||
## Guardrail Close-Out
|
|
||||||
|
|
||||||
- Outcome: keep
|
|
||||||
- Livewire v4.0+ compliance remained intact across the touched Filament v5 pages, widgets, resources, and Livewire-backed actions.
|
|
||||||
- Provider registration location remains unchanged in `bootstrap/providers.php`; no panel registration changes were needed.
|
|
||||||
- Global-search scope remains unchanged; no new searchable resources were introduced.
|
|
||||||
- Destructive actions remain confirmation-protected where applicable. The existing `regenerate` review-pack action keeps its confirmation requirement, while the new entitlement denials are non-destructive business-state blocks enforced before `ReviewPack` or `OperationRun` creation.
|
|
||||||
- Asset strategy remains unchanged. No new Filament assets were added; deploy behavior still uses `cd apps/platform && php artisan filament:assets` when registered assets are shipped.
|
|
||||||
- Validation lanes completed:
|
|
||||||
- Targeted unit entitlement lane: completed earlier in the feature implementation loop for `WorkspaceEntitlementResolver` and `WorkspacePlanProfileCatalog`.
|
|
||||||
- Targeted settings and onboarding feature lane: completed earlier in the feature implementation loop for workspace settings and managed-tenant onboarding gating.
|
|
||||||
- Targeted review-pack and system-directory feature lane: `31 passed (133 assertions)` on the final post-format run.
|
|
||||||
- Browser smoke note: attempted because changed surfaces were user-facing, but classified as environment-blocked. The integrated browser could reach `/admin` with a synthetic session cookie, but tenant-panel route resolution stayed on 404s and the system panel continued redirecting to `/system/login`, so no reliable PASS/FAIL smoke result could be established.
|
|
||||||
- Document-in-feature note: shared review-pack blocked-state wording remains centralized in the resource/service helper path. Direct tooltip introspection on the wrapped recordless Filament header action was not stable proof in the test harness, so the final assertion strategy validates the shared tooltip helper text and disabled UI state instead of comparing the wrapped action tooltip object directly.
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
# Quickstart: Plans, Entitlements & Billing Readiness
|
|
||||||
|
|
||||||
**Date**: 2026-04-27
|
|
||||||
**Branch**: `247-plans-entitlements-billing-readiness`
|
|
||||||
|
|
||||||
This quickstart is the intended reviewer flow after implementation. It stays bounded to the first slice described in the spec.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
1. Start the local platform stack.
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d`
|
|
||||||
2. Ensure one workspace member has `workspace_settings.manage`, one workspace owner can complete onboarding, one reporting operator can manage review packs, and one platform user has `platform.directory.view`.
|
|
||||||
3. Seed or factory-create:
|
|
||||||
- one workspace with no entitlement overrides
|
|
||||||
- one workspace at or above the managed-tenant activation limit
|
|
||||||
- one managed-tenant onboarding draft in the target workspace
|
|
||||||
- one tenant and one tenant review capable of review-pack generation
|
|
||||||
|
|
||||||
## Scenario 1: Configure workspace commercial truth
|
|
||||||
|
|
||||||
1. Open `/admin/settings/workspace` as a workspace manager.
|
|
||||||
2. Confirm the page shows a `Plan profile` selector and the two first-slice entitlement controls.
|
|
||||||
3. Save a plan profile with no overrides.
|
|
||||||
4. Confirm the page shows:
|
|
||||||
- the effective managed-tenant limit
|
|
||||||
- whether review-pack generation is enabled
|
|
||||||
- source labels pointing to the plan profile
|
|
||||||
- current managed-tenant usage
|
|
||||||
5. Add an explicit override and rationale for one entitlement.
|
|
||||||
6. Save again and confirm the effective source switches to workspace override and the rationale is visible.
|
|
||||||
7. Reset the override and confirm the effective value returns to the plan-profile default.
|
|
||||||
|
|
||||||
## Scenario 2: Gate managed-tenant onboarding activation
|
|
||||||
|
|
||||||
1. Open `/admin/onboarding/{onboardingDraft}` for a workspace that is within limit.
|
|
||||||
2. Confirm the completion step shows the current active managed-tenant usage and allows `Complete onboarding`.
|
|
||||||
3. Repeat with a workspace at or above its limit.
|
|
||||||
4. Confirm:
|
|
||||||
- the completion action remains visible for an otherwise authorized actor
|
|
||||||
- the action explains why onboarding is blocked
|
|
||||||
- no tenant activation occurs
|
|
||||||
5. Repeat with a workspace override that raises the limit and confirm the source label changes to workspace override.
|
|
||||||
|
|
||||||
## Scenario 3: Gate review-pack generation without creating a run
|
|
||||||
|
|
||||||
1. Use a workspace where review-pack generation is enabled.
|
|
||||||
2. Trigger generation from each current entry family:
|
|
||||||
- tenant dashboard review-pack card
|
|
||||||
- review register export action
|
|
||||||
- tenant review detail export action
|
|
||||||
- review-pack list header generate action
|
|
||||||
- review-pack detail regenerate action
|
|
||||||
3. Confirm the current queued-start UX remains unchanged when allowed.
|
|
||||||
4. Switch to a workspace where review-pack generation is disabled.
|
|
||||||
5. Repeat the same actions and confirm:
|
|
||||||
- each surface shows the same entitlement-based reason
|
|
||||||
- no new `ReviewPack` row is created
|
|
||||||
- no new `OperationRun` row is created
|
|
||||||
- existing `View` and `Download` access to already-generated review packs still works under current artifact permissions
|
|
||||||
|
|
||||||
## Scenario 4: Inspect the read-only system summary
|
|
||||||
|
|
||||||
1. Open `/system/directory/workspaces/{workspace}` as a platform user with `platform.directory.view`.
|
|
||||||
2. Confirm the page shows:
|
|
||||||
- the effective plan profile
|
|
||||||
- both entitlement decisions
|
|
||||||
- source labels
|
|
||||||
- override rationale when present
|
|
||||||
- last changed attribution
|
|
||||||
3. Confirm there are no mutation controls on the system page.
|
|
||||||
|
|
||||||
## RBAC and Plane Semantics Checks
|
|
||||||
|
|
||||||
1. Access admin-plane entitlement surfaces as a non-member or wrong-workspace actor and confirm 404.
|
|
||||||
2. Access the same surfaces as a workspace member lacking the relevant capability and confirm 403.
|
|
||||||
3. Access the action as an otherwise authorized actor whose workspace is not entitled and confirm a truthful business-state block instead of 403 or 404.
|
|
||||||
4. Access the system page as an admin-plane actor and confirm wrong-plane behavior does not leak workspace entitlement truth.
|
|
||||||
|
|
||||||
## Targeted Validation Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceEntitlementResolverTest.php tests/Unit/Entitlements/WorkspacePlanProfileCatalogTest.php
|
|
||||||
|
|
||||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php
|
|
||||||
|
|
||||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPacks/ReviewPackEntitlementEnforcementTest.php tests/Feature/System/Directory/ViewWorkspaceEntitlementsTest.php
|
|
||||||
|
|
||||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
|
||||||
```
|
|
||||||
|
|
||||||
## Out of Scope Confirmations
|
|
||||||
|
|
||||||
While validating this slice, confirm that the implementation does not add or imply:
|
|
||||||
|
|
||||||
- checkout or invoice UI
|
|
||||||
- payment-provider credentials or adapters
|
|
||||||
- customer-account, subscription, or contract records
|
|
||||||
- trial, grace-period, suspension, or renewal lifecycle states
|
|
||||||
- broader entitlement matrices outside the two first-slice keys
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
# Research: Plans, Entitlements & Billing Readiness
|
|
||||||
|
|
||||||
**Date**: 2026-04-27
|
|
||||||
**Branch**: `247-plans-entitlements-billing-readiness`
|
|
||||||
|
|
||||||
## Decision 1: Persist workspace commercial truth in existing `workspace_settings`
|
|
||||||
|
|
||||||
- **Decision**: Store the first-slice workspace commercial truth through explicit `WorkspaceSetting` keys in an `entitlements` domain, reusing `SettingsRegistry`, `SettingsResolver`, and `SettingsWriter`.
|
|
||||||
- **Rationale**: The repo already has validated, audited workspace-scoped settings persistence and a singleton workspace settings page. Reusing that path keeps the slice narrow, keeps audit behavior consistent, and avoids inventing a billing or account persistence model.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- New `plans`, `subscriptions`, or `customer_accounts` tables: rejected because the spec explicitly forbids broad billing/account scope.
|
|
||||||
- One nested JSON blob for all entitlement fields: rejected because explicit keys better fit existing page save/reset patterns, validation, and audit attribution.
|
|
||||||
|
|
||||||
## Decision 2: Keep the plan-profile catalog code-owned and bounded
|
|
||||||
|
|
||||||
- **Decision**: Represent plan-profile defaults as a small code-owned catalog with one code-owned default profile and bounded named profile identifiers, not as operator-editable data.
|
|
||||||
- **Rationale**: The first slice needs deterministic defaults when no workspace-specific selection exists, but it does not need a management UI, a billing backoffice, or a pricing model. Code-owned defaults are the narrowest current-release truth.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Database-backed plan catalog: rejected because there is no current product workflow for editing plans.
|
|
||||||
- External billing/provider sync: rejected because the spec explicitly excludes payment providers and subscription lifecycle work.
|
|
||||||
|
|
||||||
## Decision 3: Introduce one bounded `WorkspaceEntitlementResolver`
|
|
||||||
|
|
||||||
- **Decision**: Add one bounded resolver that projects effective entitlement decisions from plan defaults, workspace overrides, override rationale, and current usage.
|
|
||||||
- **Rationale**: Existing settings helpers resolve raw setting values but do not answer the operator question the feature actually needs: what is the effective value, where did it come from, why is it overridden, what is current usage, and may this action proceed now?
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Rebuild the logic independently on `WorkspaceSettings`, `ManagedTenantOnboardingWizard`, and each review-pack entry surface: rejected because it would immediately create wording drift and inconsistent enforcement.
|
|
||||||
- Extend `SettingsResolver` to absorb entitlement-specific usage logic: rejected because that would over-specialize a generic settings utility.
|
|
||||||
|
|
||||||
## Decision 4: Keep hard enforcement at the existing mutation and run-start boundaries
|
|
||||||
|
|
||||||
- **Decision**: Enforce onboarding entitlement in `ManagedTenantOnboardingWizard::canCompleteOnboarding()` and `completeOnboarding()`, and enforce review-pack entitlement inside `ReviewPackService::generate()` and `generateFromReview()`, while UI surfaces render the same decision state ahead of action execution.
|
|
||||||
- **Rationale**: Review-pack generation already fans out through several Filament actions, but those surfaces converge on `ReviewPackService`. Putting hard enforcement at the service boundary prevents bypass. Onboarding completion is already owned by the wizard page and should remain there.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- UI-only disabling on each action surface: rejected because it would not protect direct Livewire action execution.
|
|
||||||
- A second cross-cutting action framework for entitlement checks: rejected because the slice only needs one bounded business decision path, not a new platform hook system.
|
|
||||||
|
|
||||||
## Decision 5: Preserve explicit RBAC versus business-state semantics
|
|
||||||
|
|
||||||
- **Decision**: Keep 404 for non-members and wrong-plane actors, keep 403 for members missing capability, and model entitlement denial as a visible business-state block for otherwise authorized actors.
|
|
||||||
- **Rationale**: The repo constitution already distinguishes membership isolation from capability denial. Entitlements are neither. Treating entitlement denial as 403 or 404 would erase the operator-visible truth this slice exists to provide.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Hide blocked actions completely: rejected because the spec requires operator-visible rationale.
|
|
||||||
- Return 403 for entitlement denial: rejected because it conflates product policy with authorization.
|
|
||||||
|
|
||||||
## Decision 6: Keep system visibility read-only on the existing workspace directory page
|
|
||||||
|
|
||||||
- **Decision**: Expose the resolved plan profile, entitlement values, source, and last-changed attribution on `App\Filament\System\Pages\Directory\ViewWorkspace` and its existing Blade view, with no system-plane mutation control.
|
|
||||||
- **Rationale**: Platform support needs visibility into current workspace commercial truth, but introducing a second mutation plane would immediately create duplicate truth and cross-plane drift.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- New system resource or admin-like settings page: rejected because the first slice is explicitly read-only on `/system`.
|
|
||||||
- Linking support users back to `/admin` without any local visibility: rejected because it keeps support dependent on plane switching and tribal knowledge.
|
|
||||||
|
|
||||||
## Decision 7: Keep review-pack shared OperationRun UX unchanged when entitled
|
|
||||||
|
|
||||||
- **Decision**: Preserve existing `OperationUxPresenter`, `OperationRunLinks`, dedupe behavior, and queued background generation semantics whenever review-pack generation is entitled.
|
|
||||||
- **Rationale**: The feature is about whether generation is allowed, not about rebuilding review-pack run UX. The right insertion point is before run creation, not inside the shared run lifecycle.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Localize new review-pack blocked/queued UX per surface: rejected because the repo already centralizes the run-start UX.
|
|
||||||
- Add a new entitlement-specific notification family: rejected because blocked attempts should stop quietly with truthful local action messaging and no new run.
|
|
||||||
|
|
||||||
## Decision 8: Prove the slice with focused Sail/Pest unit and feature coverage only
|
|
||||||
|
|
||||||
- **Decision**: Cover the new resolver/profile defaults with unit tests and prove settings, onboarding, review-pack gating, and system visibility with focused feature tests run through Sail.
|
|
||||||
- **Rationale**: The business risk is decision correctness and action enforcement, not browser layout or broad workflow orchestration. Unit plus feature lanes are enough to prove the slice without dragging in heavy-governance or browser cost.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Browser tests: rejected because no browser-only interaction or layout risk is introduced.
|
|
||||||
- Heavy-governance suite expansion: rejected because the scope is bounded and local to existing surfaces.
|
|
||||||
|
|
||||||
## Decision 9: Leave Filament panel registration, global search, and assets unchanged
|
|
||||||
|
|
||||||
- **Decision**: Do not add panels, providers, global-search resources, or new Filament asset registrations as part of this slice.
|
|
||||||
- **Rationale**: The feature is workspace-first entitlement truth inside existing admin and system surfaces. Filament infrastructure changes would widen scope without helping the first release.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- New commercial panel or system sub-panel: rejected because the slice reuses current surfaces.
|
|
||||||
- Asset-backed custom billing UI components: rejected because native Filament components and the existing system Blade page are sufficient.
|
|
||||||
@ -1,327 +0,0 @@
|
|||||||
# Feature Specification: Plans, Entitlements & Billing Readiness
|
|
||||||
|
|
||||||
**Feature Branch**: `247-plans-entitlements-billing-readiness`
|
|
||||||
**Created**: 2026-04-27
|
|
||||||
**Status**: Draft
|
|
||||||
**Input**: User description: "Update the existing candidate as a workspace-first entitlement foundation. Keep the candidate title recognizable, but scope the first slice to one workspace-owned plan profile, explicit entitlement overrides with rationale, operator-visible decision truth, and first enforcement on managed-tenant onboarding activation plus review-pack generation. Reuse existing workspace settings, capability registries, onboarding, review-pack, and system-directory surfaces. Do not assume checkout, invoices, payment providers, proration, or a separate customer-account domain."
|
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
|
||||||
|
|
||||||
- **Problem**: TenantPilot has no product-owned workspace truth for what a workspace is commercially allowed to do, so plan availability and limit decisions live in founder memory, ad hoc support explanations, or scattered future guesses instead of one auditable runtime decision path.
|
|
||||||
- **Today's failure**: Authorized operators can reach activation or review-pack entry points without any product-side entitlement explanation, while the product cannot truthfully answer why a feature is unavailable, whether a workspace is over its allowed managed-tenant count, or which manual override currently applies.
|
|
||||||
- **User-visible improvement**: Workspace admins can set one plan profile and explicit override values once, and operators then see a calm but truthful allow-or-block reason directly on onboarding activation, review-pack generation, and system support views.
|
|
||||||
- **Smallest enterprise-capable version**: Extend the existing workspace settings foundation with one workspace plan profile, two first-slice entitlement keys, explicit workspace override values with rationale, one derived entitlement resolver, read-only system visibility, and server-enforced checks on managed-tenant onboarding activation plus review-pack generation.
|
|
||||||
- **Explicit non-goals**: No customer-account domain, no subscription lifecycle engine, no invoices, no checkout, no payment provider integration, no proration, no public pricing surface, no trial/grace/suspension workflow, no seat or report-retention matrix, no tenant/user/export/deletion entitlement spread beyond the two first-slice checks, and no platform-wide backoffice billing framework.
|
|
||||||
- **Permanent complexity imported**: One bounded plan-profile catalog, one bounded first-slice entitlement key catalog, one derived entitlement decision/resolution layer, one new entitlement section on the existing workspace settings page, one read-only system summary, and focused unit plus feature coverage.
|
|
||||||
- **Why now**: This candidate is upstream of customer lifecycle communication, demo and trial readiness, and broader commercial readiness. Adjacent self-service and support candidates are already specced, and they need a truthful entitlement source before later commercial workflows can remain narrow.
|
|
||||||
- **Why not local**: The same commercial truth must drive workspace settings, onboarding activation, review-pack generation, and system operator visibility. Local conditionals on each surface would drift immediately and recreate the current manual explanation problem.
|
|
||||||
- **Approval class**: Core Enterprise
|
|
||||||
- **Red flags triggered**: New semantic axis, foundation-sounding theme, and multi-surface touchpoint. Defense: this slice is explicitly limited to existing workspace settings, two entitlement keys, two runtime enforcement points, and one read-only system summary. It does not introduce a customer-account model, payment flow, or broad plan matrix.
|
|
||||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
|
||||||
- **Decision**: approve
|
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
|
||||||
|
|
||||||
- **Scope**: workspace
|
|
||||||
- **Primary Routes**:
|
|
||||||
- `/admin/settings/workspace` on `App\Filament\Pages\Settings\WorkspaceSettings`
|
|
||||||
- `/admin/onboarding/{onboardingDraft}` on `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`
|
|
||||||
- `/admin/reviews` on `App\Filament\Pages\Reviews\ReviewRegister`
|
|
||||||
- existing review-pack generation entry surfaces on the tenant dashboard, tenant review detail pages, and review-pack registry/detail surfaces backed by `App\Services\ReviewPackService`
|
|
||||||
- `/system/directory/workspaces/{workspace}` on `App\Filament\System\Pages\Directory\ViewWorkspace`
|
|
||||||
- **Data Ownership**: Current-release entitlement truth is workspace-owned and stored through existing `WorkspaceSetting` records plus code-owned plan-profile defaults. Managed-tenant counts, review-pack runs, and existing artifacts remain derived from current workspace and tenant truth. No new billing/account/customer-subscription table is introduced.
|
|
||||||
- **RBAC**: Workspace membership remains the isolation boundary for `/admin`. `Capabilities::WORKSPACE_SETTINGS_VIEW` and `Capabilities::WORKSPACE_SETTINGS_MANAGE` govern configuration visibility and mutation. `Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE` and `Capabilities::REVIEW_PACK_MANAGE` remain the execution capabilities on the first enforcement surfaces. `PlatformCapabilities::DIRECTORY_VIEW` governs read-only system visibility. Non-members and wrong-plane actors receive 404. Members missing capability receive 403. Members with capability but without entitlement receive a truthful business-state block rather than a hidden surface or false 403.
|
|
||||||
|
|
||||||
For canonical-view specs, the spec MUST define:
|
|
||||||
|
|
||||||
- **Default filter behavior when tenant-context is active**: N/A - this slice is workspace-owned and does not introduce a tenantless cross-tenant collection that filters tenant records.
|
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: N/A - existing tenant access rules remain authoritative. The new workspace entitlement truth never reveals tenant-owned records outside the current workspace or platform directory visibility.
|
|
||||||
|
|
||||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
|
||||||
|
|
||||||
- **Cross-cutting feature?**: yes
|
|
||||||
- **Interaction class(es)**: configuration settings, status messaging, action gating/helper text, review-pack start UX, read-only system diagnostics
|
|
||||||
- **Systems touched**: `WorkspaceSetting`, `SettingsResolver`, `SettingsWriter`, workspace audit logging, canonical capability registries, managed-tenant onboarding activation, review-pack generation entry surfaces, and the system directory workspace detail view
|
|
||||||
- **Existing pattern(s) to extend**: existing workspace settings update/reset + audit pattern, existing capability-gated Filament actions, existing review-pack queued-start UX, and existing system directory detail summaries
|
|
||||||
- **Shared contract / presenter / builder / renderer to reuse**: `App\Services\Settings\SettingsResolver`, `App\Services\Settings\SettingsWriter`, `App\Services\Audit\WorkspaceAuditLogger`, `App\Support\Auth\Capabilities`, `App\Support\Auth\PlatformCapabilities`, `App\Support\OpsUx\OperationUxPresenter`, and `App\Support\OperationRunLinks`; one new bounded `WorkspaceEntitlementResolver` (or equivalently named resolver) is introduced because there is no existing shared commercial-decision path
|
|
||||||
- **Why the existing shared path is sufficient or insufficient**: The existing settings stack is already sufficient for workspace-owned persistence, validation, and audit. It is insufficient for runtime commercial truth because multiple surfaces need the same resolved plan-default versus override decision, source attribution, and usage context without duplicating business rules.
|
|
||||||
- **Allowed deviation and why**: none. The feature must not create page-local entitlement checks, local billing copy, or a second support-facing commercial summary.
|
|
||||||
- **Consistency impact**: Plan profile labels, entitlement source labels, blocked copy, override rationale labels, and current-usage summaries must mean the same thing on workspace settings, onboarding activation, review-pack generation, and the system directory page.
|
|
||||||
- **Review focus**: Reviewers must verify that the same resolved decision object drives all first-slice surfaces, that no surface invents its own commercial vocabulary, and that existing review-pack operation UX remains unchanged when entitlement allows execution.
|
|
||||||
|
|
||||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
|
||||||
|
|
||||||
- **Touches OperationRun start/completion/link UX?**: yes
|
|
||||||
- **Shared OperationRun UX contract/layer reused**: Existing review-pack generation continues to use `App\Services\ReviewPackService`, `App\Support\OpsUx\OperationUxPresenter`, and `App\Support\OperationRunLinks`. Managed-tenant onboarding activation remains a confirmed, audited page mutation and does not introduce a new `OperationRun`.
|
|
||||||
- **Delegated start/completion UX behaviors**: When review-pack generation is entitled, queued toast, `Open operation` link, dedupe handling, browser event dispatch, and terminal lifecycle notifications stay on the existing shared path. When generation is not entitled, no run is created and no queued or terminal notification is emitted.
|
|
||||||
- **Local surface-owned behavior that remains**: Workspace settings save and reset actions, onboarding completion helper text and callouts, and blocked reason presentation on review-pack entry surfaces remain surface-owned.
|
|
||||||
- **Queued DB-notification policy**: unchanged. The feature adds no new queued DB notification behavior and no new review-pack run type.
|
|
||||||
- **Terminal notification path**: central lifecycle mechanism for existing review-pack generation only
|
|
||||||
- **Exception required?**: none
|
|
||||||
|
|
||||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
|
||||||
|
|
||||||
N/A - no shared provider/platform boundary touched. The new plan and entitlement vocabulary is platform-core and must remain provider-neutral even when it gates provider-backed review-pack generation.
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
|
||||||
|
|
||||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| Workspace settings entitlement section | yes | Native Filament + existing singleton settings page | settings, status messaging, helper text | page, section, resolved settings summary | no | Extends the existing singleton page instead of creating a new admin surface |
|
|
||||||
| Managed tenant onboarding completion gate | yes | Native Filament wizard + existing completion action | action gating, callouts, helper text | wizard step, confirmation action | no | Reuses the existing completion step and keeps onboarding calm |
|
|
||||||
| Review-pack generation entry family | yes | Native Filament widget/resource actions | operation start gating, helper text, queued-start UX | widget action, detail action, list/header action | no | One entitlement decision must cover all current `Generate pack`, `Regenerate`, and `Export executive pack` entry points |
|
|
||||||
| System directory workspace entitlement summary | yes | Native Filament system detail page | read-only diagnostics, support/commercial visibility | detail section/card | no | Read-only only in the first slice |
|
|
||||||
|
|
||||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| Workspace settings entitlement section | Primary Decision Surface | Workspace owner or manager decides what this workspace is commercially allowed to do | Current plan profile, effective entitlements, source, rationale, and current usage summary | Audit attribution and related affected surfaces | Primary because this is the one configuration point that changes later runtime behavior | Configuration first, enforcement second | Removes ad hoc founder-only plan decisions and scattered explanations |
|
|
||||||
| Managed tenant onboarding completion gate | Primary Decision Surface | Operator decides whether the tenant may be activated now | Activation eligibility, current active managed-tenant usage versus allowed limit, and the one next action | Existing verification and bootstrap diagnostics remain secondary | Primary because onboarding completion is the actual high-impact decision point for tenant activation | Keeps commercial truth inside the onboarding workflow instead of forcing cross-page lookup | Prevents silent failure or false calmness at the moment of activation |
|
|
||||||
| Review-pack generation entry family | Secondary Context Surface | Operator decides whether to start or retry review-pack generation from the current tenant or review context | Allow-or-block state, source, and the next step when blocked | Existing operation detail, review-pack status, and artifact truth stay secondary | Not primary because the surface exists to continue reporting/review workflows, not to manage commercial posture | Stays inside the existing reporting workflow | Avoids back-and-forth to support or settings just to understand why generation is blocked |
|
|
||||||
| System directory workspace entitlement summary | Tertiary Evidence / Diagnostics Surface | Platform operator or support user verifies what entitlement truth is active for a workspace | Resolved plan profile, effective entitlement values, source, and last changed attribution | Existing tenant counts, recent runs, and admin workspace link stay supporting context | Not primary because system operators inspect rather than change commercial posture in this slice | Supports support and escalation workflows without adding a second mutation plane | Avoids separate manual lookup across admin pages, audit logs, and founder memory |
|
|
||||||
|
|
||||||
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| 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 |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| Workspace settings entitlement section | operator-MSP | Plan profile, two first-slice entitlements, current usage, source, and saveable override inputs | Last modified attribution and reset state | none | `Save` | Any future billing/account metadata and broader commercial lifecycle fields stay out of scope | The same resolved values shown here are reused on downstream surfaces instead of reworded locally |
|
|
||||||
| Managed tenant onboarding completion gate | operator-MSP | Activation eligibility, active managed-tenant count, limit source, and concise blocked reason | Verification and operability detail already present on the wizard | none | `Complete onboarding` or a clear blocked explanation when unavailable | Broader commercial configuration stays off the onboarding page | The onboarding step shows only the one commercial fact needed for activation and does not restate full settings data |
|
|
||||||
| Review-pack generation entry family | operator-MSP | Review-pack generation availability, source, and the next step when blocked | Existing queued/run state and artifact status remain secondary | none | The in-context start action: `Generate pack`, `Regenerate`, or `Export executive pack` when allowed | Full workspace plan configuration stays off these surfaces | The same entitlement reason object is rendered consistently across the widget, Review Register, tenant review detail, and review-pack resource actions |
|
|
||||||
| System directory workspace entitlement summary | support-platform | Read-only plan profile, effective entitlement values, source, and last changed attribution | Tenant counts, recent runs, and admin workspace link | none | `Open admin workspace` | Mutation controls and raw settings payload stay hidden in the system plane | The system page mirrors resolved truth only and does not become a second editable source |
|
|
||||||
|
|
||||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Workspace settings entitlement section | Config / Settings / Singleton | Workspace configuration page | Save or reset an entitlement-related setting | In-page settings section | forbidden | Per-field reset actions and helper text stay inside the section | None beyond confirmed reset of overrides if added | `/admin/settings/workspace` | `/admin/settings/workspace` | Active workspace context | Plan profile / Entitlements | Effective values, source, rationale, and usage | Singleton-settings exception already exists and remains bounded |
|
|
||||||
| Managed tenant onboarding completion gate | Workflow / Guided action entry | Onboarding completion step | Complete onboarding or stop because the workspace is over limit | In-page completion section | forbidden | Back-navigation and linked-tenant navigation stay secondary | Existing `Cancel draft` and `Delete draft` header actions remain destructive and confirmation-protected | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Workspace context plus linked tenant identity | Onboarding entitlement | Activation eligibility and current limit usage | Guided-workflow exception remains valid |
|
|
||||||
| Review-pack generation entry family | Contextual action family | Tenant widget plus Review Register, tenant review detail, and tenant-scoped review-pack registry/detail actions | Start or retry review-pack generation when allowed | Explicit action on the current tenant or review context | mixed - forbidden on widget/detail actions, existing clickable row remains on the registry | Existing `View` and `Download` actions remain secondary and outside the entitlement gate; `Generate pack`, `Regenerate`, and `Export executive pack` are the only in-scope gated actions | Existing expire or similar destructive actions remain where the current resource contract places them | current tenant dashboard, `/admin/reviews`, tenant review detail, and tenant review-pack registry | current tenant dashboard, tenant review detail, and tenant review-pack registry/view | Active workspace, active tenant, and current review or review-pack context | Review pack generation entitlement | Allowed or blocked state and why | Grouped-action family exception documented here to avoid divergent gating |
|
|
||||||
| System directory workspace entitlement summary | System / Detail / Diagnostics | Read-only workspace detail page | Inspect current workspace commercial truth | Dedicated workspace detail page | forbidden | Existing admin-workspace and runs links stay secondary | none | `/system/directory/workspaces` | `/system/directory/workspaces/{workspace}` | Platform workspace identity and tenant count | Workspace entitlement summary | Effective plan profile, source, and last change attribution | Read-only system diagnostic surface |
|
|
||||||
|
|
||||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Workspace settings entitlement section | Workspace owner or manager | Set or clear the workspace commercial posture for the first slice | Singleton settings page | What is this workspace allowed to do, and do I need to override the default? | Current plan profile, managed-tenant activation limit, review-pack generation availability, source, rationale, and current usage | Last modified attribution and reset availability | commercial profile, entitlement source, current usage | TenantPilot only | Save, Reset override | none |
|
|
||||||
| Managed tenant onboarding completion gate | Workspace owner completing managed tenant onboarding | Decide whether onboarding may be completed now | Guided workflow step | Can I activate this managed tenant under the current workspace entitlements? | Active managed-tenant usage, allowed limit, source, blocked reason, and existing completion prerequisites | Existing verification/operability diagnostics | onboarding readiness, entitlement eligibility | TenantPilot only for completion state; Microsoft tenant only for existing provider actions already on the page | Complete onboarding | Cancel draft, Delete draft |
|
|
||||||
| Review-pack generation entry family | Workspace manager or reporting operator | Decide whether to start or retry review-pack generation | Widget/action family | Can I start `Generate pack`, `Regenerate`, or `Export executive pack` from this workspace under the current entitlements? | Review-pack generation availability, source, and blocked reason | Existing run state, artifact truth, and review status; `View` and `Download` stay outside the entitlement decision | entitlement availability, run state, artifact status | TenantPilot only until the existing generation flow starts; then existing review-pack run semantics apply | Generate pack, Export executive pack, Regenerate | Existing destructive actions remain unchanged and out of scope |
|
|
||||||
| System directory workspace entitlement summary | Platform support or operations user | Verify workspace commercial truth without switching planes | Read-only detail page | What plan and overrides are currently in effect for this workspace? | Resolved plan profile, entitlement values, source, and last changed attribution | Recent runs, tenant counts, and admin workspace link | commercial profile, entitlement source | none | Open admin workspace | none |
|
|
||||||
|
|
||||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
|
||||||
|
|
||||||
- **New source of truth?**: yes - workspace-owned plan profile and explicit entitlement override truth become current-release business truth, but they are stored in the existing workspace settings mechanism rather than a new table
|
|
||||||
- **New persisted entity/table/artifact?**: no
|
|
||||||
- **New abstraction?**: yes - one bounded resolver for effective entitlement decisions across multiple surfaces
|
|
||||||
- **New enum/state/reason family?**: yes - one bounded plan-profile identifier set, one bounded first-slice entitlement key catalog, and a small entitlement source vocabulary
|
|
||||||
- **New cross-domain UI framework/taxonomy?**: no
|
|
||||||
- **Current operator problem**: Operators and support users cannot truthfully explain whether a workspace is allowed to activate more managed tenants or generate review packs, and the product currently has no auditable commercial decision path.
|
|
||||||
- **Existing structure is insufficient because**: Generic settings storage alone does not provide a consistent runtime decision path, source attribution, usage context, or blocked-action explanation across onboarding, reporting, and system support surfaces.
|
|
||||||
- **Narrowest correct implementation**: Keep persistence inside existing workspace settings, limit the catalog to two entitlement keys, derive current usage from existing workspace and artifact truth, add one bounded resolver, and gate only two existing runtime actions in the first slice.
|
|
||||||
- **Ownership cost**: One new resolver and small catalog need ongoing tests and vocabulary review. One settings section and one system summary need ongoing UX discipline. No new tables, background workflows, or billing-provider seams are introduced.
|
|
||||||
- **Alternative intentionally rejected**: A new `Plan`, `Subscription`, or `CustomerAccount` model family was rejected because the repo has no current account or payment domain, and the first slice only needs workspace-owned runtime entitlement truth.
|
|
||||||
- **Release truth**: current-release truth with explicit follow-up candidates for later billing lifecycle work
|
|
||||||
|
|
||||||
### Compatibility posture
|
|
||||||
|
|
||||||
This feature assumes a pre-production environment.
|
|
||||||
|
|
||||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
|
||||||
|
|
||||||
Canonical replacement is preferred over preservation.
|
|
||||||
|
|
||||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
|
||||||
|
|
||||||
- **Test purpose / classification**: Unit, Feature
|
|
||||||
- **Validation lane(s)**: fast-feedback, confidence
|
|
||||||
- **Why this classification and these lanes are sufficient**: Unit coverage proves plan-profile defaults, override merging, source attribution, and current-usage calculation. Focused feature coverage proves the existing Filament settings page, onboarding completion gate, review-pack generation gate, and system directory visibility without adding browser or heavy-governance scope.
|
|
||||||
- **New or expanded test families**: one new `Entitlements` unit family plus focused feature coverage for workspace settings, onboarding, review-pack generation, and system-directory visibility
|
|
||||||
- **Fixture / helper cost impact**: Add only workspace, membership, active managed-tenant, review-pack-capable tenant/review, and platform-user fixtures required to prove the first-slice decisions. Avoid new browser harnesses, payment-provider mocks, or broad commercial seeds.
|
|
||||||
- **Heavy-family visibility / justification**: none
|
|
||||||
- **Special surface test profile**: standard-native-filament, monitoring-state-page, shared-detail-family
|
|
||||||
- **Standard-native relief or required special coverage**: Standard Filament feature coverage is sufficient for the workspace settings page and onboarding wizard. Review-pack gating also needs monitoring-state assertions to prove that blocked attempts do not create a run, while the system directory page needs one read-only platform-plane detail assertion.
|
|
||||||
- **Reviewer handoff**: Reviewers must confirm that entitlement denials are distinct from 404 and 403 RBAC outcomes, blocked review-pack actions never create an `OperationRun`, lowered limits do not mutate existing tenants or packs, and the same reason text is reused across all first-slice surfaces.
|
|
||||||
- **Budget / baseline / trend impact**: low feature-local increase only
|
|
||||||
- **Escalation needed**: none
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Planned validation commands**:
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceEntitlementResolverTest.php tests/Unit/Entitlements/WorkspacePlanProfileCatalogTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPacks/ReviewPackEntitlementEnforcementTest.php tests/Feature/System/Directory/ViewWorkspaceEntitlementsTest.php`
|
|
||||||
|
|
||||||
## Scope Boundaries *(required for this slice)*
|
|
||||||
|
|
||||||
### In Scope
|
|
||||||
|
|
||||||
- One workspace-owned plan profile selected and audited through the existing workspace settings surface
|
|
||||||
- Exactly two first-slice entitlement keys:
|
|
||||||
- active managed-tenant activation limit for onboarding completion
|
|
||||||
- review-pack generation availability for existing `Generate pack`, `Regenerate`, and `Export executive pack` entry points
|
|
||||||
- Explicit workspace override values for those keys with operator-entered rationale and reset-to-default behavior
|
|
||||||
- One derived effective entitlement decision path showing value, source, rationale, and current usage where applicable
|
|
||||||
- Read-only system-plane visibility of the resolved workspace commercial truth on the existing workspace directory page
|
|
||||||
|
|
||||||
### Non-Goals
|
|
||||||
|
|
||||||
- Trial, grace, suspension, cancellation, or renewal lifecycle states
|
|
||||||
- Checkout, invoices, payment collection, taxes, proration, or billing-provider adapters
|
|
||||||
- A separate customer account, subscription, contract, or offer domain model
|
|
||||||
- Broader entitlement spread across seats, exports, retention, user counts, support SLAs, or feature flags
|
|
||||||
- Platform-plane mutation or emergency override controls in the first slice
|
|
||||||
- Customer-facing plan self-service or website pricing integration
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- Existing `WorkspaceSetting`, `SettingsResolver`, and `SettingsWriter` are sufficient persistence and audit primitives for the current-release commercial truth.
|
|
||||||
- The first slice may use a small code-owned plan-profile catalog because there is no existing billing or account model to import from.
|
|
||||||
- Managed-tenant activation limit is measured against the current workspace's active managed tenants and blocks future activation only; it does not retroactively deactivate existing tenants.
|
|
||||||
- Review-pack generation entitlement governs new `Generate pack`, `Regenerate`, and `Export executive pack` attempts only; existing `View` and `Download` access to already-generated artifacts continues to follow current artifact and capability rules.
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- Plan-profile naming can become prematurely productized if the catalog expands beyond the two first-slice entitlements.
|
|
||||||
- Partial gating would be misleading if one review-pack entry point enforces entitlement while another bypasses it.
|
|
||||||
- Lowering a managed-tenant limit below current usage creates an over-limit workspace that must be explained carefully so operators are not misled into expecting retroactive enforcement.
|
|
||||||
- Support users could misread the system view as a second source of truth if the admin and system surfaces drift in wording or source labels.
|
|
||||||
|
|
||||||
## Follow-up Candidates
|
|
||||||
|
|
||||||
- Customer lifecycle communication driven by plan and entitlement state
|
|
||||||
- Demo and trial readiness built on top of the same workspace commercial truth
|
|
||||||
- Broader entitlement keys for exports, retention, seats, and support-plan limits
|
|
||||||
- External billing, subscription, or contract integration once a real account domain exists
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - Configure workspace commercial truth in one place (Priority: P1)
|
|
||||||
|
|
||||||
As a workspace owner or manager, I want to set the workspace plan profile and any first-slice overrides on the existing workspace settings page so later runtime behavior is predictable and attributable.
|
|
||||||
|
|
||||||
**Why this priority**: The first slice fails immediately if the product cannot define one authoritative workspace entitlement posture before runtime enforcement begins.
|
|
||||||
|
|
||||||
**Independent Test**: Open the existing workspace settings page, save a plan profile and one override with rationale, then verify that the resolved values, source, and current usage summary update without touching any onboarding or reporting surface.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a workspace manager can access workspace settings, **When** they save a plan profile with no overrides, **Then** the page shows the resolved first-slice entitlements from that profile and records the change through the existing workspace-setting audit path.
|
|
||||||
2. **Given** a workspace manager sets an explicit override for one entitlement, **When** they save the change with rationale, **Then** the resolved source changes to workspace override and the rationale is visible on the page.
|
|
||||||
3. **Given** a workspace manager resets an override, **When** the reset completes, **Then** the effective value returns to the plan-profile default and the reset is attributable in audit history.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Truthfully gate managed-tenant activation (Priority: P1)
|
|
||||||
|
|
||||||
As an authorized onboarding operator, I want the final onboarding step to tell me whether the workspace may activate another managed tenant and why, so I do not complete onboarding under a false assumption.
|
|
||||||
|
|
||||||
**Why this priority**: Managed-tenant activation is the highest-risk first-slice lifecycle mutation. It needs a truthful commercial gate before later trial or lifecycle specs build on top of it.
|
|
||||||
|
|
||||||
**Independent Test**: Seed workspaces under limit, at limit, and over limit, open the existing onboarding completion step, and verify that the same action is allowed or blocked with the right reason before any tenant activation mutation occurs.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a workspace is within its allowed managed-tenant limit and the actor has onboarding activation capability, **When** they reach the existing completion step, **Then** the step shows the current limit usage and allows completion.
|
|
||||||
2. **Given** a workspace is at or above its allowed managed-tenant limit, **When** the same actor reaches the completion step, **Then** the action remains visible but blocked with a truthful explanation and no tenant activation occurs.
|
|
||||||
3. **Given** the workspace has an explicit override that increases the allowed limit, **When** the actor reaches the completion step, **Then** the action uses the override value and labels the source accordingly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Truthfully gate review-pack generation and expose the reason to support (Priority: P2)
|
|
||||||
|
|
||||||
As a reporting operator or platform support user, I want review-pack generation to use the same entitlement decision everywhere and be inspectable from the system directory so I can explain blocked behavior without guesswork.
|
|
||||||
|
|
||||||
**Why this priority**: Review-pack generation is an existing shared product workflow with several entry points. If it is not gated consistently, the product will immediately create conflicting commercial behavior.
|
|
||||||
|
|
||||||
**Independent Test**: Seed a workspace where review-pack generation is disabled, attempt generation from current tenant or review surfaces, confirm no new run is created, and then verify the same resolved reason on the read-only system workspace page.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** review-pack generation is enabled for a workspace and the actor has `review_pack.manage`, **When** they start generation from an existing entry surface, **Then** the existing queued review-pack flow continues unchanged.
|
|
||||||
2. **Given** review-pack generation is disabled for that workspace, **When** the same actor attempts generation or regeneration from any current entry surface, **Then** the action is blocked before any `OperationRun` or `ReviewPack` is created and the reason matches the workspace entitlement decision.
|
|
||||||
3. **Given** a platform user with `platform.directory.view` opens the system workspace detail page, **When** they inspect the workspace entitlement summary, **Then** they can see the resolved plan profile, source, effective values, and last changed attribution without changing the admin-plane truth.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- A workspace with no explicit plan profile override must still resolve deterministically from the system default plan profile so operators never see an unset commercial state.
|
|
||||||
- Lowering the managed-tenant limit below the workspace's current active count must not deactivate existing tenants; it only blocks future onboarding activation and must show the workspace as over limit.
|
|
||||||
- Disabling review-pack generation must not remove access to already-generated review packs, downloads, or run history that remain allowed under existing artifact permissions.
|
|
||||||
- If review-pack generation is already queued and the workspace later becomes not entitled, existing runs may complete, but new `Generate pack`, `Regenerate`, or `Export executive pack` attempts must block from that point forward.
|
|
||||||
- A user who is a workspace member but lacks `workspace_settings.manage`, `workspace_managed_tenant.onboard.activate`, or `review_pack.manage` must still receive 403 for those actions even when the workspace itself is entitled.
|
|
||||||
- A non-member or wrong-plane actor must not learn whether a workspace is over limit or review-pack generation is disabled; those requests continue to resolve as 404.
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
**Constitution alignment (required):** This feature adds runtime-changing workspace-owned product truth and mutating settings writes, but it does not add Microsoft Graph calls, new provider dispatch, or a new queued workflow family. Workspace plan-profile and override changes use the existing workspace-setting audit path. Review-pack generation continues to rely on the existing `OperationRun`-backed flow only when entitlement allows the action.
|
|
||||||
|
|
||||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces new workspace-owned commercial truth and one bounded resolver because current-release operator workflows need one consistent decision across settings, onboarding, reporting, and system support. A narrower approach would still scatter plan logic across surfaces. The feature deliberately avoids a new table, customer-account model, or billing lifecycle state machine.
|
|
||||||
|
|
||||||
**Constitution alignment (XCUT-001):** All first-slice surfaces must use the same effective entitlement decision object for value, source, rationale, and current usage. No surface may invent local blocked copy or local plan semantics.
|
|
||||||
|
|
||||||
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** Settings and action surfaces must show only the one commercial fact needed for the current decision. Support or audit detail remains secondary. No surface may present a neutral or success-like state when the workspace is actually blocked by entitlement or limit usage.
|
|
||||||
|
|
||||||
**Constitution alignment (PROV-001):** Plan profile, entitlement, override, and current-usage vocabulary remain platform-neutral. The feature must not introduce provider-shaped plan keys or account semantics.
|
|
||||||
|
|
||||||
**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit plus feature lanes. New fixtures remain local to workspace, tenant, review-pack, and platform-directory contexts. No browser or heavy-governance family is justified for this slice.
|
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX):** The feature does not add a new run family. Existing review-pack generation keeps the current queued toast, progress, and terminal notification path. Blocked review-pack attempts must not create a run, and therefore must not emit run lifecycle notifications.
|
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX-START-001):** Review-pack generation entry surfaces continue delegating queued-start UX and canonical links to the shared review-pack and operation-run path. The new entitlement gate must sit before run creation rather than replacing that shared path.
|
|
||||||
|
|
||||||
**Constitution alignment (RBAC-UX):** Two authorization planes are involved: tenant/admin `/admin` and system `/system`. Wrong-plane or non-member access remains 404. Members missing capability remain 403. Entitlement denial for an otherwise authorized actor is a product-state block, not a membership failure. Existing destructive-like actions such as onboarding draft cancellation and deletion remain confirmation-protected. Any new override reset action must also use explicit confirmation if it materially changes runtime access.
|
|
||||||
|
|
||||||
**Constitution alignment (BADGE-001):** If the slice introduces status or source badges for entitlement state, those semantics must be centralized and reused across admin and system views rather than implemented with page-local color logic.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-FIL-001):** The first slice extends existing native Filament pages, widgets, actions, sections, callouts, and detail views. No custom backoffice shell or local billing panel is allowed.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** Primary operator labels must stay product-facing and specific: `Plan profile`, `Managed tenant limit`, `Review pack generation`, `Override reason`, `Complete onboarding`, `Generate pack`, and `Open admin workspace`. Terms such as subscription, checkout, invoice, proration, or Stripe must not appear.
|
|
||||||
|
|
||||||
**Constitution alignment (DECIDE-001):** Workspace settings remains the one primary commercial decision surface. Onboarding and review-pack surfaces remain contextual decision points that show only the commercial truth needed for the action at hand. The system directory page remains read-only evidence.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The feature must preserve the existing singleton settings, guided workflow, grouped review-pack actions, and read-only system detail patterns. It may not add redundant inspect actions, shadow settings routes, or mixed action groups that hide the blocked reason.
|
|
||||||
|
|
||||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** Settings mutation stays on the workspace settings page. Onboarding completion remains the primary action at the completion step. Review-pack generation remains the primary reporting action where already present. Navigation and diagnostics stay secondary.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** One thin decision layer is justified because direct reads from raw settings would still force each surface to rebuild merge rules, source attribution, and current usage. Tests must target business outcomes such as allowed versus blocked execution and correct source labeling, not cosmetic rendering alone.
|
|
||||||
|
|
||||||
**Constitution alignment (Filament Action Surfaces):** The action-surface contract remains satisfied with documented existing exceptions for the singleton settings page and onboarding wizard. The review-pack generation entry family must keep the existing `Generate pack`, `Regenerate`, and `Export executive pack` start actions in scope, leave existing `View` and `Download` affordances outside the entitlement gate, and must not add redundant inspect affordances or placeholder action groups.
|
|
||||||
|
|
||||||
**Constitution alignment (UX-001 - Layout & Information Architecture):** The settings changes stay inside the existing sectioned workspace settings page. Onboarding and review-pack surfaces keep the current layout and only add bounded decision truth. The system directory page remains a detail/information surface rather than a second settings panel.
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-247-001 Workspace-owned truth**: The system MUST represent first-slice commercial truth at workspace scope by storing one selected plan profile and optional explicit workspace overrides through the existing workspace settings infrastructure instead of introducing a new billing or customer-account persistence model.
|
|
||||||
- **FR-247-002 First-slice entitlement catalog**: The first slice MUST resolve exactly two entitlement keys: active managed-tenant activation limit and review-pack generation availability. No other entitlement family is required in this spec.
|
|
||||||
- **FR-247-003 Effective decision shape**: The system MUST derive an effective decision for each first-slice entitlement that includes the effective value, source, operator-visible rationale, and current usage when the entitlement is limit-based.
|
|
||||||
- **FR-247-004 Plan profile defaults**: The system MUST provide a bounded, code-owned plan-profile catalog whose defaults drive the two first-slice entitlement keys. The catalog is a product configuration artifact, not a customer contract or payment record.
|
|
||||||
- **FR-247-005 Explicit overrides**: Authorized workspace managers MUST be able to set or reset explicit override values for each first-slice entitlement together with rationale, and the resulting change MUST be attributable through the existing workspace-setting audit path.
|
|
||||||
- **FR-247-006 Workspace settings visibility**: The existing workspace settings page MUST show the current plan profile, effective first-slice entitlements, source labels, rationale, and current usage summary after save without requiring the operator to inspect code, logs, or system pages.
|
|
||||||
- **FR-247-007 Managed-tenant activation enforcement**: The existing onboarding completion action MUST consult the active managed-tenant entitlement decision before activation. If the workspace is over limit, the action MUST remain visible to otherwise authorized actors, explain why completion is blocked, and stop before tenant activation mutates runtime state.
|
|
||||||
- **FR-247-008 Review-pack generation enforcement**: All current `Generate pack`, `Regenerate`, and `Export executive pack` entry points MUST consult the same review-pack entitlement decision before creating or reusing a `ReviewPack` or `OperationRun`. If blocked, the action MUST stop before any run or artifact is created.
|
|
||||||
- **FR-247-009 Existing artifact access unchanged**: Disabling review-pack generation MUST NOT revoke view or download access to existing review packs that remain accessible under current artifact permissions.
|
|
||||||
- **FR-247-010 Over-limit behavior**: Lowering a managed-tenant limit below current active usage MUST mark the workspace as over limit for future activation attempts, but MUST NOT deactivate or archive existing tenants.
|
|
||||||
- **FR-247-011 System-plane visibility**: The existing system directory workspace page MUST show a read-only summary of the resolved plan profile, effective first-slice entitlements, source labels, and last changed attribution to platform users with `platform.directory.view`.
|
|
||||||
- **FR-247-012 Entitlement versus RBAC semantics**: Non-members and wrong-plane actors MUST continue to receive 404. Members missing the relevant capability MUST receive 403. Actors who are otherwise authorized but whose workspace is not entitled MUST receive a truthful product-state block and no silent bypass.
|
|
||||||
- **FR-247-013 No hidden commercial platform**: The first slice MUST NOT introduce checkout, invoices, payment collection, proration, trial/grace/suspension lifecycle state, customer-account records, or any external billing-provider seam.
|
|
||||||
|
|
||||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
|
||||||
|
|
||||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Workspace settings entitlement section | `app/Filament/Pages/Settings/WorkspaceSettings.php` | `Save` | N/A - singleton settings page | none | none | N/A | N/A | `Save`; per-setting `Reset` actions for plan profile and overrides | yes - existing workspace-setting update/reset audit path | Existing singleton-page exemption remains valid |
|
|
||||||
| Managed tenant onboarding completion gate | `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` | `Back to workspace`, `Back to onboarding`, `View tenant`, existing `Cancel draft`, existing `Delete draft` | N/A - guided workflow | none | none | existing onboarding start state remains unchanged | same header actions apply on the route-bound page | `Complete onboarding` remains confirmation-protected and now also entitlement-gated | yes - existing onboarding audit semantics remain | Existing wizard exception remains valid |
|
|
||||||
| Review-pack generation entry family | `app/Filament/Widgets/Tenant/TenantReviewPackCard.php`, `app/Filament/Pages/Reviews/ReviewRegister.php`, `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/ReviewPackResource.php` | current `Generate pack`, `Regenerate`, or `Export executive pack` entry actions stay primary | Existing clickable row remains only on the review-pack registry | existing `Download` remains the only direct row shortcut on the registry and stays outside the entitlement gate | none | existing `Generate` CTA remains on empty states where already present | existing `Download` and `Regenerate` header actions stay in place; only `Generate pack`, `Regenerate`, and `Export executive pack` are gated | N/A - action family, not a create/edit form | existing review-pack generation behavior unchanged; no new entitlement-block audit required | Grouped action family documented here so all in-scope start actions share one gate while `View` and `Download` remain unaffected |
|
|
||||||
| System directory workspace entitlement summary | `app/Filament/System/Pages/Directory/ViewWorkspace.php` | none | dedicated page route only | none | none | N/A | existing admin-workspace and runs links remain secondary navigation | N/A | no new audit action; read-only visibility only | Read-only system detail surface |
|
|
||||||
|
|
||||||
### Key Entities *(include if feature involves data)*
|
|
||||||
|
|
||||||
- **Workspace Plan Profile**: A bounded workspace-owned plan identifier that maps to the two first-slice entitlement defaults. It is not a subscription, contract, invoice, or customer account.
|
|
||||||
- **Workspace Entitlement Override**: An explicit workspace-scoped override value for one first-slice entitlement key together with operator rationale and audit attribution, persisted through the existing workspace settings stack.
|
|
||||||
- **Effective Entitlement Decision**: A derived runtime decision containing effective value, source, rationale, and current usage summary, reused by settings, onboarding, review-pack, and system visibility surfaces.
|
|
||||||
|
|
||||||
## Success Criteria *(mandatory)*
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: Authorized workspace managers can set or reset the first-slice commercial posture from one workspace settings page and see the resolved values, source, and current usage immediately after saving.
|
|
||||||
- **SC-002**: Authorized operators can determine from the onboarding completion step or a review-pack entry surface in under 30 seconds whether the action is allowed, blocked by plan profile, or blocked by current limit usage, without opening logs or asking support.
|
|
||||||
- **SC-003**: 100% of first-slice blocked executions stop before tenant activation or review-pack run creation and show a truthful reason instead of silently hiding the action or implying success.
|
|
||||||
- **SC-004**: Platform operators with read-only directory access can inspect the effective workspace plan profile, entitlement source, and last changed attribution from one system detail page without switching to a second source of truth.
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
---
|
|
||||||
|
|
||||||
description: "Task list for feature implementation"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Tasks: Plans, Entitlements & Billing Readiness
|
|
||||||
|
|
||||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/`
|
|
||||||
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/contracts/workspace-entitlements-foundation.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md`
|
|
||||||
|
|
||||||
**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in focused `Unit` and `Feature` lanes only, using the targeted Sail commands already captured in the feature spec, plan, and quickstart artifacts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: Setup (Shared Infrastructure)
|
|
||||||
|
|
||||||
**Purpose**: Align the bounded first slice, validation entry points, and repo guardrails before touching runtime code.
|
|
||||||
|
|
||||||
- [x] T001 Review the bounded slice, explicit non-goals, and guardrail outcomes in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/checklists/requirements.md`
|
|
||||||
- [x] T002 [P] Review the logical route and action boundaries for workspace settings, onboarding completion, review-pack generation, and system-directory visibility in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/contracts/workspace-entitlements-foundation.logical.openapi.yaml`
|
|
||||||
- [x] T003 [P] Start or confirm the focused validation environment through `apps/platform/vendor/bin/sail` and keep the planned proof commands aligned with `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Prerequisites)
|
|
||||||
|
|
||||||
**Purpose**: Add the shared entitlement primitives that every user story depends on.
|
|
||||||
|
|
||||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
|
||||||
|
|
||||||
- [x] T004 [P] Add the first-slice entitlement setting keys, validation metadata, and operator-facing labels to `apps/platform/app/Support/Settings/SettingsRegistry.php`
|
|
||||||
- [x] T005 [P] Add the bounded code-owned plan profile catalog for the two first-slice entitlement defaults in `apps/platform/app/Services/Entitlements/WorkspacePlanProfileCatalog.php`
|
|
||||||
- [x] T006 Implement the shared effective entitlement decision shape, source vocabulary, rationale projection, and managed-tenant usage aggregation in `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php`
|
|
||||||
- [x] T007 Wire the new entitlement keys through the existing audited settings stack in `apps/platform/app/Services/Settings/SettingsResolver.php` and `apps/platform/app/Services/Settings/SettingsWriter.php`
|
|
||||||
|
|
||||||
**Checkpoint**: Foundation ready. User story work can now proceed independently.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: User Story 1 - Configure Workspace Commercial Truth In One Place (Priority: P1) 🎯 MVP
|
|
||||||
|
|
||||||
**Goal**: Let a workspace manager set one plan profile and two bounded entitlement overrides with rationale from the existing workspace settings page.
|
|
||||||
|
|
||||||
**Independent Test**: Open `/admin/settings/workspace`, save a plan profile and one override with rationale, then confirm the page shows the resolved values, source, rationale, and current usage summary without touching onboarding or review-pack surfaces.
|
|
||||||
|
|
||||||
### Tests for User Story 1
|
|
||||||
|
|
||||||
- [x] T008 [P] [US1] Add plan-profile and resolver unit coverage for default fallback, override merge, source attribution, rationale, and over-limit calculation in `apps/platform/tests/Unit/Entitlements/WorkspacePlanProfileCatalogTest.php` and `apps/platform/tests/Unit/Entitlements/WorkspaceEntitlementResolverTest.php`
|
|
||||||
- [x] T009 [P] [US1] Add workspace-settings feature coverage for save, reset, rationale validation, source labels, and audit attribution in `apps/platform/tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 1
|
|
||||||
|
|
||||||
- [x] T010 [US1] Extend the existing workspace settings section with a plan profile selector, two override inputs, and rationale inputs in `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php`
|
|
||||||
- [x] T011 [US1] Add confirmation-protected override reset actions that clear both override value and rationale on `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php`
|
|
||||||
- [x] T012 [US1] Persist plan-profile and override writes through the existing audited settings path in `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` and `apps/platform/app/Services/Settings/SettingsWriter.php`
|
|
||||||
- [x] T013 [US1] Render the shared resolver output back onto the settings page as effective source, rationale, and current usage summary in `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` and `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php`
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 1 is independently functional and ready for focused settings validation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: User Story 2 - Truthfully Gate Managed-Tenant Activation (Priority: P1)
|
|
||||||
|
|
||||||
**Goal**: Keep the onboarding completion action visible to authorized actors while blocking activation with a truthful entitlement reason whenever the workspace is at or over its managed-tenant limit.
|
|
||||||
|
|
||||||
**Independent Test**: Seed workspaces within limit, at limit, and over limit, open the existing onboarding completion step, and confirm the action is either allowed or blocked with the correct reason before any tenant activation mutation occurs.
|
|
||||||
|
|
||||||
### Tests for User Story 2
|
|
||||||
|
|
||||||
- [x] T014 [P] [US2] Add onboarding feature coverage for within-limit, at-limit, over-limit, override-source, and 404 versus 403 versus business-state semantics in `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 2
|
|
||||||
|
|
||||||
- [x] T015 [US2] Project the shared managed-tenant entitlement decision onto the onboarding completion step in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
|
||||||
- [x] T016 [US2] Enforce the managed-tenant activation entitlement before any onboarding completion mutation occurs in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
|
||||||
- [x] T017 [US2] Keep the onboarding helper text, source labels, and business-state block logic sourced from `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php` inside `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 2 is independently functional and preserves truthful activation gating without retroactive tenant mutation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: User Story 3 - Truthfully Gate Review-Pack Generation And Expose The Reason To Support (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: Reuse one entitlement decision for all current `Generate pack`, `Regenerate`, and `Export executive pack` entry points, while exposing the same resolved truth read-only on the system workspace page.
|
|
||||||
|
|
||||||
**Independent Test**: Seed a workspace where review-pack generation is disabled, attempt `Generate pack`, `Regenerate`, and `Export executive pack` from the existing entry families, confirm no new `ReviewPack` or `OperationRun` is created, confirm existing `View`/`Download` access still works, and then confirm the same resolved reason on the system workspace detail page.
|
|
||||||
|
|
||||||
### Tests for User Story 3
|
|
||||||
|
|
||||||
- [x] T018 [P] [US3] Add review-pack and system-directory feature coverage for blocked `Generate pack`, `Regenerate`, and `Export executive pack` actions, preserved allowed flow, explicit `View`/`Download` no-regression behavior, no-run enforcement, and read-only entitlement visibility in `apps/platform/tests/Feature/ReviewPacks/ReviewPackEntitlementEnforcementTest.php` and `apps/platform/tests/Feature/System/Directory/ViewWorkspaceEntitlementsTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 3
|
|
||||||
|
|
||||||
- [x] T019 [US3] Enforce review-pack entitlement before `ReviewPack` or `OperationRun` creation for `Generate pack`, `Regenerate`, and `Export executive pack` flows while preserving existing queued-start UX and leaving `View`/`Download` behavior unchanged in `apps/platform/app/Services/ReviewPackService.php`
|
|
||||||
- [x] T020 [P] [US3] Gate the tenant dashboard review-pack card and the Review Register `Export executive pack` entry point with resolver-backed allow-or-block messaging in `apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php` and `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`
|
|
||||||
- [x] T021 [P] [US3] Gate only the in-scope `Generate pack`, `Regenerate`, and `Export executive pack` actions on tenant review and review-pack resource surfaces with the same shared decision projection, while leaving existing `View` and `Download` access unchanged, in `apps/platform/app/Filament/Resources/TenantReviewResource.php`, `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `apps/platform/app/Filament/Resources/ReviewPackResource.php`, `apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ListReviewPacks.php`, and `apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`
|
|
||||||
- [x] T022 [P] [US3] Add the read-only resolved entitlement summary to the system workspace detail surface in `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and `apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php`
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 3 is independently functional and keeps review-pack gating and system visibility on the same decision truth.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Polish & Cross-Cutting Concerns
|
|
||||||
|
|
||||||
**Purpose**: Finish guardrail close-out, run the narrow validation commands, and format touched files without widening scope.
|
|
||||||
|
|
||||||
- [x] T023 Record the final guardrail close-out, lane result, and any bounded `document-in-feature` note for shared entitlement wording or surface exceptions in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/checklists/requirements.md`
|
|
||||||
- [x] T024 Run the targeted unit Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md` against `apps/platform/tests/Unit/Entitlements/WorkspaceEntitlementResolverTest.php` and `apps/platform/tests/Unit/Entitlements/WorkspacePlanProfileCatalogTest.php`
|
|
||||||
- [x] T025 Run the targeted settings and onboarding Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md` against `apps/platform/tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php` and `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`
|
|
||||||
- [x] T026 Run the targeted review-pack and system-directory Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md` against `apps/platform/tests/Feature/ReviewPacks/ReviewPackEntitlementEnforcementTest.php` and `apps/platform/tests/Feature/System/Directory/ViewWorkspaceEntitlementsTest.php`
|
|
||||||
- [x] T027 Run dirty-only formatting for touched platform files through `apps/platform/vendor/bin/sail` using the Pint command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies & Execution Order
|
|
||||||
|
|
||||||
### Phase Dependencies
|
|
||||||
|
|
||||||
- **Phase 1 (Setup)**: no dependencies; start immediately.
|
|
||||||
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
|
|
||||||
- **Phase 3 (US1)**, **Phase 4 (US2)**, and **Phase 5 (US3)**: each depends on Phase 2 and is independently testable after the shared settings and resolver primitives exist.
|
|
||||||
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
|
|
||||||
|
|
||||||
### User Story Dependencies
|
|
||||||
|
|
||||||
- **US1 (P1)**: starts after Phase 2 and does not depend on US2 or US3.
|
|
||||||
- **US2 (P1)**: starts after Phase 2 and reuses the shared resolver, but does not require US1 UI work to be complete.
|
|
||||||
- **US3 (P2)**: starts after Phase 2 and reuses the shared resolver plus settings keys, but does not require onboarding work to be complete.
|
|
||||||
|
|
||||||
### Within Each User Story
|
|
||||||
|
|
||||||
- Write the listed Pest tests first and make them fail for the intended behavior gap.
|
|
||||||
- Complete shared service enforcement before wiring multiple entry points that depend on it.
|
|
||||||
- Keep each story shippable on its own before moving to the next story.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Parallel Execution Examples
|
|
||||||
|
|
||||||
### User Story 1
|
|
||||||
|
|
||||||
- Run T008 and T009 in parallel.
|
|
||||||
- After T010 starts the settings section, keep T011 and T012 coordinated because both touch `WorkspaceSettings.php`.
|
|
||||||
|
|
||||||
### User Story 2
|
|
||||||
|
|
||||||
- Run T014 in parallel with any remaining US1 validation work after Phase 2 is complete.
|
|
||||||
- Keep T015, T016, and T017 sequential because they all tighten the same onboarding completion boundary.
|
|
||||||
|
|
||||||
### User Story 3
|
|
||||||
|
|
||||||
- Run T018 first, then complete T019 before splitting T020, T021, and T022 across separate files.
|
|
||||||
- T020, T021, and T022 can proceed in parallel once the service-level gate in `ReviewPackService.php` is in place.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Strategy
|
|
||||||
|
|
||||||
### Suggested MVP Scope
|
|
||||||
|
|
||||||
- MVP = **User Story 1** only. It establishes the workspace-owned plan profile, two entitlement keys, explicit overrides with rationale, and the shared decision truth that every later gate depends on.
|
|
||||||
|
|
||||||
### Incremental Delivery
|
|
||||||
|
|
||||||
1. Complete Phase 1 and Phase 2.
|
|
||||||
2. Deliver US1 and validate the settings-backed commercial truth.
|
|
||||||
3. Deliver US2 and validate onboarding activation gating.
|
|
||||||
4. Deliver US3 and validate review-pack gating plus read-only system visibility.
|
|
||||||
5. Finish with the Phase 6 guardrail close-out, focused validation commands, and formatting.
|
|
||||||
Loading…
Reference in New Issue
Block a user