Compare commits
10 Commits
240-tenant
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| aacd82849a | |||
| ff3392892b | |||
| e222845a36 | |||
| 6e3736a53f | |||
| 86505483bf | |||
| bf43e55848 | |||
| 6053d87b99 | |||
| d96abc65fb | |||
| 17d3ca8313 | |||
| ab6eccaf40 |
12
.github/agents/copilot-instructions.md
vendored
12
.github/agents/copilot-instructions.md
vendored
@ -256,6 +256,12 @@ ## Active Technologies
|
|||||||
- Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope)
|
- Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope)
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 (239-canonical-operation-type-source-of-truth)
|
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 (239-canonical-operation-type-source-of-truth)
|
||||||
- PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables (239-canonical-operation-type-source-of-truth)
|
- PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables (239-canonical-operation-type-source-of-truth)
|
||||||
|
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers (240-tenant-onboarding-readiness)
|
||||||
|
- PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness)
|
||||||
|
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` (241-support-diagnostic-pack)
|
||||||
|
- PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack)
|
||||||
|
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services (249-customer-review-workspace)
|
||||||
|
- PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -290,9 +296,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4
|
- 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
|
||||||
- 238-provider-identity-target-scope: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4
|
- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`
|
||||||
- 237-provider-boundary-hardening: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4
|
- 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|
||||||
### Pre-production compatibility check
|
### Pre-production compatibility check
|
||||||
|
|||||||
447
.github/skills/spec-kit-implementation-loop/SKILL.md
vendored
Normal file
447
.github/skills/spec-kit-implementation-loop/SKILL.md
vendored
Normal file
@ -0,0 +1,447 @@
|
|||||||
|
---
|
||||||
|
name: spec-kit-implementation-loop
|
||||||
|
description: Implement an existing TenantPilot/TenantAtlas Spec Kit feature, run tests, browser smoke checks where applicable, post-implementation analysis, fix all confirmed in-scope findings when safe and bounded, and repeat until no in-scope findings remain or a stop condition is reached.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: Spec Kit Implementation Loop
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Use this skill to implement an already prepared TenantPilot/TenantAtlas Spec Kit feature and verify it with a bounded implementation loop.
|
||||||
|
|
||||||
|
This skill assumes `spec.md`, `plan.md`, and `tasks.md` already exist and have passed preparation readiness or have been explicitly accepted by the user.
|
||||||
|
|
||||||
|
The intended workflow is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
active or explicitly named spec
|
||||||
|
→ inspect repo truth, constitution, spec, plan, tasks, and relevant code/tests
|
||||||
|
→ evaluate implementation gates
|
||||||
|
→ implement strictly task-by-task
|
||||||
|
→ run relevant tests/checks
|
||||||
|
→ run browser smoke test when UI/user-facing flows are affected
|
||||||
|
→ run strict post-implementation analysis
|
||||||
|
→ fix confirmed in-scope findings
|
||||||
|
→ repeat test + browser smoke + analysis + fix loop until clean or bounded stop condition is reached
|
||||||
|
→ final implementation report
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use this skill when the user asks to:
|
||||||
|
|
||||||
|
- implement an active or explicitly named Spec Kit feature
|
||||||
|
- run Spec Kit implement
|
||||||
|
- analyze after implementation
|
||||||
|
- fix implementation findings
|
||||||
|
- repeat implementation verification until no confirmed in-scope findings remain
|
||||||
|
- run tests and browser smoke checks after implementation
|
||||||
|
|
||||||
|
Typical user prompts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Implementiere die aktive Spec und analysiere danach, ob alles passt.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Implementiere specs/243-product-usage-adoption-telemetry streng nach tasks.md.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Mach Spec Kit implement und danach analyse. Behebe alle Abweichungen und wiederhole bis sauber.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Implementiere die vorbereitete Spec. Danach Tests, Browser Smoke Test falls UI betroffen ist, Analyse und Fix-Loop bis keine In-Scope Findings mehr offen sind.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hard Rules
|
||||||
|
|
||||||
|
- Work strictly repo-based.
|
||||||
|
- Implement only the active or explicitly named Spec Kit feature.
|
||||||
|
- Do not choose a new candidate.
|
||||||
|
- Do not create a new spec.
|
||||||
|
- Do not expand scope beyond `spec.md`, `plan.md`, and `tasks.md`.
|
||||||
|
- Do not silently add roadmap features, adjacent UX rewrites, speculative architecture, or unrelated refactors.
|
||||||
|
- Follow the repository constitution and existing Spec Kit conventions.
|
||||||
|
- Preserve TenantPilot/TenantAtlas terminology.
|
||||||
|
- Prefer small, reviewable patches over broad rewrites.
|
||||||
|
- Treat repository truth as authoritative over assumptions.
|
||||||
|
- If repository truth conflicts with implementation scope, stop and report the conflict unless there is an obvious minimal correction inside active spec scope.
|
||||||
|
- Fix only confirmed findings from tests, static checks, browser smoke checks, or post-implementation analysis.
|
||||||
|
- Fix all confirmed in-scope findings, regardless of severity, when they are safe and bounded.
|
||||||
|
- Do not leave Medium/Low findings open silently. If they are not fixed, document exactly why.
|
||||||
|
- Never hide failing tests, weaken assertions, delete meaningful coverage, or mark tasks complete without implementation evidence.
|
||||||
|
- Do not run destructive commands.
|
||||||
|
- Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||||
|
- Do not perform database-destructive actions unless the repository test workflow explicitly requires isolated test database resets.
|
||||||
|
- Do not continue analysis/fix loops indefinitely.
|
||||||
|
- Do not move from implementation to final status unless the Test Gate, Browser Smoke Test Gate where applicable, and Post-Implementation Analysis Gate have been evaluated.
|
||||||
|
- Do not claim merge-readiness unless the Merge Readiness Gate passes.
|
||||||
|
|
||||||
|
## Required Inputs
|
||||||
|
|
||||||
|
The user should provide at least one of:
|
||||||
|
|
||||||
|
- explicit spec directory such as `specs/<number>-<slug>/`
|
||||||
|
- instruction to use the current active Spec Kit feature
|
||||||
|
- instruction to implement the prepared/current spec
|
||||||
|
|
||||||
|
If the active spec cannot be determined safely, inspect the repository Spec Kit context first. If it is still ambiguous, stop and ask for the specific spec directory.
|
||||||
|
|
||||||
|
## Required Repository Checks
|
||||||
|
|
||||||
|
Always check:
|
||||||
|
|
||||||
|
1. active Spec Kit context / current branch
|
||||||
|
2. git status
|
||||||
|
3. `.specify/memory/constitution.md`
|
||||||
|
4. the active spec directory
|
||||||
|
5. `spec.md`
|
||||||
|
6. `plan.md`
|
||||||
|
7. `tasks.md`
|
||||||
|
8. relevant templates or conventions under `.specify/templates/`
|
||||||
|
9. nearby existing specs with related terminology or scope
|
||||||
|
10. application code surfaces referenced by the active spec
|
||||||
|
11. existing tests related to the changed behavior
|
||||||
|
|
||||||
|
## Git and Branch Safety
|
||||||
|
|
||||||
|
Before making implementation changes:
|
||||||
|
|
||||||
|
1. Check the current branch.
|
||||||
|
2. Check whether the working tree is clean.
|
||||||
|
3. If there are unrelated uncommitted changes, stop and report them. Do not continue.
|
||||||
|
4. If the working tree only contains user-intended changes for this operation, continue cautiously.
|
||||||
|
5. Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||||
|
6. Do not overwrite unrelated work.
|
||||||
|
|
||||||
|
## Quality Gates
|
||||||
|
|
||||||
|
### Gate 1: Spec Readiness Gate
|
||||||
|
|
||||||
|
Required before implementation starts.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- `spec.md`, `plan.md`, and `tasks.md` exist.
|
||||||
|
- The spec has clear problem statement, user value, functional requirements, out-of-scope boundaries, acceptance criteria, assumptions, and risks.
|
||||||
|
- The plan identifies likely affected repo surfaces and does not contradict repository architecture.
|
||||||
|
- The tasks are small, ordered, verifiable, and include test/validation tasks.
|
||||||
|
- RBAC, workspace/tenant isolation, auditability, OperationRun semantics, evidence/result-truth, and UX requirements are addressed where relevant.
|
||||||
|
- No open question blocks safe implementation.
|
||||||
|
- The scope is small enough for a bounded implementation loop.
|
||||||
|
|
||||||
|
Fail behavior:
|
||||||
|
|
||||||
|
- Stop before implementation.
|
||||||
|
- Report readiness gaps.
|
||||||
|
- Do not compensate for an unclear spec by inventing implementation scope.
|
||||||
|
|
||||||
|
### Gate 2: Implementation Scope Gate
|
||||||
|
|
||||||
|
Required before changing application code.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- The active spec directory is known.
|
||||||
|
- The implementation target is traceable to specific tasks in `tasks.md`.
|
||||||
|
- The affected files/surfaces are consistent with `plan.md` or clearly justified by repository truth.
|
||||||
|
- No required change would introduce unrelated product behavior.
|
||||||
|
- No required change conflicts with constitution, existing architecture, RBAC/isolation boundaries, or source-of-truth semantics.
|
||||||
|
|
||||||
|
Fail behavior:
|
||||||
|
|
||||||
|
- Stop before code changes and report the conflict or ambiguity.
|
||||||
|
- Suggest a minimal spec/plan/tasks correction if the issue is in the artifacts rather than the codebase.
|
||||||
|
|
||||||
|
### Gate 3: Test Gate
|
||||||
|
|
||||||
|
Required after implementation and after each fix iteration.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- Targeted tests for changed behavior pass.
|
||||||
|
- Relevant existing tests pass or failures are proven unrelated and documented.
|
||||||
|
- Static analysis, linting, formatting, or type checks used by the repository pass when applicable.
|
||||||
|
- Security/governance-relevant changes have backend, policy, or domain coverage; UI-only verification is not enough.
|
||||||
|
- Regression coverage exists for each fixed Blocker or High finding where practical.
|
||||||
|
|
||||||
|
Fail behavior:
|
||||||
|
|
||||||
|
- Fix in-scope failures before post-implementation analysis.
|
||||||
|
- If failures are unrelated or pre-existing, document evidence and continue only if they do not invalidate the active spec.
|
||||||
|
- Do not weaken tests to pass the gate.
|
||||||
|
|
||||||
|
### Gate 4: Browser Smoke Test Gate
|
||||||
|
|
||||||
|
Required before claiming implementation is ready for manual review/merge when the change affects Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow.
|
||||||
|
|
||||||
|
Not required for backend-only, domain-only, enum-only, contract-only, or test-only changes unless those changes alter a user-facing flow.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- The relevant page or flow loads in a real browser or the repository's browser-testing harness.
|
||||||
|
- The primary action introduced or changed by the spec can be executed successfully.
|
||||||
|
- Expected UI states, labels, badges, actions, empty states, tables, forms, modals, and navigation are visible where relevant.
|
||||||
|
- Workspace/tenant context is preserved across the tested flow where relevant.
|
||||||
|
- RBAC/capability-dependent visibility behaves as expected where practical to verify.
|
||||||
|
- Livewire interactions complete without visible runtime errors.
|
||||||
|
- No relevant browser console errors occur.
|
||||||
|
- No failed network requests occur for the tested flow, except known unrelated development noise that is explicitly documented.
|
||||||
|
- OperationRun, audit, evidence, result, or support-diagnostic drilldowns work where relevant.
|
||||||
|
- The smoke-tested path is documented in the final response.
|
||||||
|
|
||||||
|
Fail behavior:
|
||||||
|
|
||||||
|
- Fix in-scope browser, UX, Livewire, navigation, or runtime failures before claiming merge-readiness.
|
||||||
|
- If a browser issue is unrelated existing debt, document evidence and residual risk.
|
||||||
|
- Do not treat a passing browser smoke test as a substitute for backend, policy, domain, security, feature, or integration tests.
|
||||||
|
- Do not expand the smoke test into a full E2E suite unless the user explicitly asks for that.
|
||||||
|
|
||||||
|
### Gate 5: Post-Implementation Analysis Gate
|
||||||
|
|
||||||
|
Required after implementation and after each fix iteration.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- The implementation has been checked against `spec.md`, `plan.md`, `tasks.md`, and constitution.
|
||||||
|
- All completed tasks have implementation evidence.
|
||||||
|
- No confirmed in-scope findings remain.
|
||||||
|
- Medium/Low findings are fixed when they are inside active spec scope, clearly bounded, and safe.
|
||||||
|
- Medium/Low findings that remain open are explicitly documented with one of these reasons:
|
||||||
|
- out of scope
|
||||||
|
- requires separate spec
|
||||||
|
- risky refactor
|
||||||
|
- existing unrelated debt
|
||||||
|
- not reproducible
|
||||||
|
- blocked by unclear product/architecture decision
|
||||||
|
- No scope expansion was introduced during fixes.
|
||||||
|
|
||||||
|
Fail behavior:
|
||||||
|
|
||||||
|
- Fix confirmed in-scope findings, regardless of severity, when the fix is safe and bounded.
|
||||||
|
- Stop instead of fixing when remediation would expand scope, contradict repo architecture, introduce risky refactors, or repeat the same failed fix twice.
|
||||||
|
|
||||||
|
### Gate 6: Merge Readiness Gate
|
||||||
|
|
||||||
|
Required before claiming the implementation is ready for manual review/merge.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- Spec Readiness Gate passed.
|
||||||
|
- Implementation Scope Gate passed.
|
||||||
|
- Test Gate passed.
|
||||||
|
- Browser Smoke Test Gate passed when applicable, or was explicitly marked not applicable with a reason.
|
||||||
|
- Post-Implementation Analysis Gate passed.
|
||||||
|
- `tasks.md` reflects actual completion status.
|
||||||
|
- No confirmed in-scope findings remain.
|
||||||
|
- All remaining findings are documented as out-of-scope, follow-up candidates, unrelated existing debt, or explicit residual risks.
|
||||||
|
- Final response includes changed files, tests/checks run, browser smoke result, iterations performed, residual risks, and follow-up candidates.
|
||||||
|
|
||||||
|
Fail behavior:
|
||||||
|
|
||||||
|
- Do not claim merge-readiness.
|
||||||
|
- Report the failed gate, remaining risks, and the smallest recommended next action.
|
||||||
|
|
||||||
|
## Implementation Loop
|
||||||
|
|
||||||
|
Execute the loop in bounded phases:
|
||||||
|
|
||||||
|
1. Evaluate the Spec Readiness Gate.
|
||||||
|
2. Evaluate the Implementation Scope Gate before changing application code.
|
||||||
|
3. Implement the active Spec Kit feature scope task-by-task.
|
||||||
|
4. Run targeted tests and relevant static/dynamic checks.
|
||||||
|
5. Evaluate the Test Gate.
|
||||||
|
6. Run a Browser Smoke Test when the change affects UI/user-facing flows.
|
||||||
|
7. Evaluate the Browser Smoke Test Gate as passed, failed, or not applicable with a reason.
|
||||||
|
8. Run strict post-implementation analysis against spec, plan, tasks, constitution, changed code, changed tests, browser smoke results where applicable, and relevant existing patterns.
|
||||||
|
9. Evaluate the Post-Implementation Analysis Gate.
|
||||||
|
10. Identify confirmed findings by severity: Blocker, High, Medium, Low.
|
||||||
|
11. Fix all confirmed in-scope findings regardless of severity when safe and bounded.
|
||||||
|
12. Do not fix findings that require scope expansion, risky unrelated refactors, or architectural/product decisions outside the active spec; document them as follow-up/residual risks with reasons.
|
||||||
|
13. Re-run relevant tests and browser smoke checks where applicable after fixes.
|
||||||
|
14. Repeat test + browser smoke + analysis + fix loop until no confirmed in-scope findings remain or a stop condition is reached.
|
||||||
|
15. Evaluate the Merge Readiness Gate.
|
||||||
|
16. Report final implementation status, changed files, tests, browser smoke result, residual risks, failed/passed gates, and manual review prompt.
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
Stop the implementation loop when any of the following is true:
|
||||||
|
|
||||||
|
- No confirmed in-scope findings remain.
|
||||||
|
- The same finding appears twice after attempted fixes.
|
||||||
|
- A required fix conflicts with the spec, plan, constitution, or repository architecture.
|
||||||
|
- A required fix would expand scope beyond the active spec.
|
||||||
|
- A required fix would require a risky unrelated refactor.
|
||||||
|
- A required fix depends on an unresolved product or architecture decision.
|
||||||
|
- Tests reveal an unrelated pre-existing failure that cannot be safely fixed inside the active spec.
|
||||||
|
- Browser smoke testing reveals an unrelated pre-existing UI/runtime failure that cannot be safely fixed inside the active spec.
|
||||||
|
- Three analysis/fix iterations have already been completed.
|
||||||
|
- The repository state is ambiguous enough that continuing would risk damaging architecture or data semantics.
|
||||||
|
|
||||||
|
When stopping before full cleanliness, report exactly why the loop stopped and what remains.
|
||||||
|
|
||||||
|
## Post-Implementation Analysis Prompt
|
||||||
|
|
||||||
|
Use this prompt internally after implementation and after each fix iteration:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
Du bist ein Senior Staff Software Engineer, Software Architect und Enterprise SaaS Reviewer.
|
||||||
|
|
||||||
|
Analysiere die Implementierung der aktiven Spec streng repo-basiert.
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Prüfe, ob die Umsetzung vollständig, konsistent, getestet und constitution-konform ist.
|
||||||
|
|
||||||
|
Prüfe gegen:
|
||||||
|
- spec.md
|
||||||
|
- plan.md
|
||||||
|
- tasks.md
|
||||||
|
- .specify/memory/constitution.md
|
||||||
|
- geänderte Anwendungscodes
|
||||||
|
- geänderte Tests
|
||||||
|
- Browser-Smoke-Test-Ergebnis, falls UI/user-facing Flows betroffen sind
|
||||||
|
- bestehende Repository-Patterns
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
- Keine Spekulation ohne Repo-Beleg.
|
||||||
|
- Keine Scope-Erweiterung.
|
||||||
|
- Keine neuen Produktideen als Pflicht-Fixes.
|
||||||
|
- Findings nach Blocker, High, Medium, Low gruppieren.
|
||||||
|
- Für jedes Finding konkrete Datei-/Code-Belege nennen.
|
||||||
|
- Für jedes Finding eine minimale Remediation nennen.
|
||||||
|
- Separat ausweisen, welche Findings innerhalb der aktiven Spec behoben werden müssen.
|
||||||
|
- Medium/Low Findings innerhalb der aktiven Spec ebenfalls zur Behebung markieren, wenn sie sicher und bounded sind.
|
||||||
|
- Bei UI-/Filament-/Livewire-Änderungen prüfen, ob ein Browser Smoke Test durchgeführt wurde und ob der getestete Operator-Flow wirklich funktioniert.
|
||||||
|
- Findings, die nicht behoben werden sollen, nur als Follow-up/Residual Risk ausweisen, wenn sie out of scope, risky refactor, unrelated existing debt, not reproducible oder durch eine offene Produkt-/Architekturentscheidung blockiert sind.
|
||||||
|
- Wenn keine bestätigten In-Scope Findings verbleiben, klare Implementierungsfreigabe geben.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Completion Rules
|
||||||
|
|
||||||
|
- Keep `tasks.md` aligned with actual implementation status.
|
||||||
|
- Check off tasks only after the implementation and test evidence exists.
|
||||||
|
- If a task is obsolete because repository truth proves a different path, update the task note with the reason instead of silently deleting it.
|
||||||
|
- If a task cannot be completed inside scope, leave it unchecked and report why.
|
||||||
|
|
||||||
|
## Testing Rules
|
||||||
|
|
||||||
|
- Add or update tests for all changed business behavior.
|
||||||
|
- Include RBAC and workspace/tenant isolation tests where relevant.
|
||||||
|
- Include OperationRun, audit, evidence, or result-truth tests where relevant.
|
||||||
|
- Prefer regression tests for every fixed Blocker or High finding.
|
||||||
|
- Add regression tests for Medium/Low findings when the behavior is important and testable without excessive churn.
|
||||||
|
- Do not weaken tests to pass the suite.
|
||||||
|
- Do not treat a green UI path as sufficient without backend or policy coverage when the behavior is security- or governance-relevant.
|
||||||
|
|
||||||
|
## Browser Smoke Test Rules
|
||||||
|
|
||||||
|
Apply these rules when the active spec changes Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow.
|
||||||
|
|
||||||
|
The browser smoke test should be narrow and focused. It is not a full E2E suite unless explicitly requested.
|
||||||
|
|
||||||
|
Minimum smoke path:
|
||||||
|
|
||||||
|
1. Open the relevant page or entry point.
|
||||||
|
2. Confirm the expected workspace/tenant context where relevant.
|
||||||
|
3. Confirm the changed or newly introduced UI element is visible.
|
||||||
|
4. Execute the primary action or interaction changed by the spec.
|
||||||
|
5. Confirm the expected result state, notification, redirect, table update, modal state, operation link, or drilldown.
|
||||||
|
6. Check for relevant console errors.
|
||||||
|
7. Check for failed network requests related to the tested flow.
|
||||||
|
8. Document the tested path in the final response.
|
||||||
|
|
||||||
|
For TenantPilot/TenantAtlas, pay special attention to:
|
||||||
|
|
||||||
|
- Filament actions and header actions
|
||||||
|
- Livewire polling, modals, validation, and actions
|
||||||
|
- workspace/tenant context preservation
|
||||||
|
- RBAC/capability-dependent action visibility
|
||||||
|
- OperationRun links and drilldown continuity
|
||||||
|
- audit/evidence/result/support-diagnostic drilldowns where relevant
|
||||||
|
- empty states, badges, labels, and decision guidance where relevant
|
||||||
|
|
||||||
|
Browser smoke testing is required for UI/user-facing changes and optional for backend-only changes.
|
||||||
|
|
||||||
|
Do not treat browser smoke success as proof that backend security, policies, domain logic, auditability, or workspace/tenant isolation are correct. Those still require automated tests or repo-based verification.
|
||||||
|
|
||||||
|
## Failure Handling
|
||||||
|
|
||||||
|
If an implementation step, test phase, browser smoke phase, or post-implementation analysis fails:
|
||||||
|
|
||||||
|
1. Stop at the relevant gate or stop condition.
|
||||||
|
2. Report the failing command or phase.
|
||||||
|
3. Summarize the error.
|
||||||
|
4. Do not attempt unrelated implementation as a workaround.
|
||||||
|
5. Suggest the smallest safe next action.
|
||||||
|
|
||||||
|
If the branch or working tree state is unsafe:
|
||||||
|
|
||||||
|
1. Stop before implementation changes.
|
||||||
|
2. Report the current branch and relevant uncommitted files.
|
||||||
|
3. Ask the user to commit, stash, or move to a clean worktree.
|
||||||
|
|
||||||
|
## Final Response Requirements
|
||||||
|
|
||||||
|
Respond with:
|
||||||
|
|
||||||
|
1. Active spec directory
|
||||||
|
2. Summary of implemented changes
|
||||||
|
3. Tests/checks run and their results
|
||||||
|
4. Browser smoke test result, tested path, or not-applicable reason
|
||||||
|
5. Quality gates passed/failed and number of analysis/fix iterations performed
|
||||||
|
6. Remaining in-scope findings, if any
|
||||||
|
7. Residual risks and follow-up candidates, if relevant
|
||||||
|
8. Files changed
|
||||||
|
9. Explicit statement whether the Merge Readiness Gate passed and whether the implementation is ready for manual review/merge
|
||||||
|
|
||||||
|
Keep the final response concise, but include enough detail for the user to continue immediately.
|
||||||
|
|
||||||
|
## Manual Review Prompt
|
||||||
|
|
||||||
|
Provide a ready-to-copy prompt like this, adapted to the active spec number and slug:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
|
||||||
|
|
||||||
|
Führe eine finale manuelle Review der implementierten Spec `<spec-number>-<slug>` streng repo-basiert durch.
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Prüfe, ob die Implementierung nach dem Agenten-Loop wirklich merge-ready ist.
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
- Keine Implementierung.
|
||||||
|
- Keine Codeänderungen.
|
||||||
|
- Keine Scope-Erweiterung.
|
||||||
|
- Prüfe gegen spec.md, plan.md, tasks.md und constitution.md.
|
||||||
|
- Prüfe die geänderten Dateien, Tests, Browser-Smoke-Test-Ergebnis, RBAC, Workspace-/Tenant-Isolation, Auditability, UX und OperationRun-Semantik, soweit relevant.
|
||||||
|
- Benenne nur konkrete Findings mit Repo-Beleg.
|
||||||
|
- Gib am Ende eine klare Entscheidung: Merge-ready, merge-ready with notes, oder not merge-ready.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Invocation
|
||||||
|
|
||||||
|
User:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nutze den Skill spec-kit-implementation-loop.
|
||||||
|
Implementiere die aktive Spec.
|
||||||
|
Danach Tests ausführen, Browser Smoke Test falls UI/user-facing betroffen ist, Post-Implementation Analyse durchführen und alle bestätigten In-Scope Findings unabhängig von Severity beheben, wenn safe und bounded.
|
||||||
|
Wiederhole test + browser smoke + analysis + fix bis keine In-Scope Findings mehr offen sind oder eine Stop Condition greift.
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
1. Inspect active Spec Kit context, constitution, spec, plan, tasks, relevant code, and relevant tests.
|
||||||
|
2. Evaluate the Spec Readiness Gate and Implementation Scope Gate.
|
||||||
|
3. Implement only the active spec scope.
|
||||||
|
4. Run targeted tests and relevant checks.
|
||||||
|
5. Evaluate the Test Gate.
|
||||||
|
6. Run and evaluate Browser Smoke Test when UI/user-facing flows are affected.
|
||||||
|
7. Run post-implementation analysis.
|
||||||
|
8. Fix all confirmed in-scope findings regardless of severity when safe and bounded.
|
||||||
|
9. Repeat test + browser smoke + analysis + fix loop up to the stop conditions.
|
||||||
|
10. Evaluate the Merge Readiness Gate.
|
||||||
|
11. Report final status, changed files, tests, browser smoke result, residual risks, gates, and manual review prompt.
|
||||||
|
```
|
||||||
398
.github/skills/spec-kit-next-best-one-shot/SKILL.md
vendored
398
.github/skills/spec-kit-next-best-one-shot/SKILL.md
vendored
@ -1,398 +0,0 @@
|
|||||||
---
|
|
||||||
name: spec-kit-next-best-one-shot
|
|
||||||
description: Select the most suitable next TenantPilot/TenantAtlas spec from roadmap and spec-candidates, then run the GitHub Spec Kit preparation flow in one pass: specify, plan, tasks, and analyze. Use when the user wants the agent to choose the next best spec, execute the real Spec Kit workflow including branch/spec-directory mechanics, analyze the generated artifacts, and fix preparation issues before implementation. This skill must not implement application code.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Skill: Spec Kit Next-Best One-Shot Preparation
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Use this skill when the user wants the agent to select the most suitable next spec from existing product planning sources and then execute the real GitHub Spec Kit preparation flow in one pass:
|
|
||||||
|
|
||||||
1. select the next best spec candidate from roadmap and spec candidates
|
|
||||||
2. run the repository's Spec Kit `specify` flow for that selected candidate
|
|
||||||
3. run the repository's Spec Kit `plan` flow for the generated spec
|
|
||||||
4. run the repository's Spec Kit `tasks` flow for the generated plan
|
|
||||||
5. run the repository's Spec Kit `analyze` flow against the generated artifacts
|
|
||||||
6. fix issues in Spec Kit preparation artifacts only (`spec.md`, `plan.md`, `tasks.md`, and related Spec Kit metadata if required)
|
|
||||||
7. stop before implementation
|
|
||||||
8. provide a concise readiness summary for the user
|
|
||||||
|
|
||||||
This skill must use the repository's actual Spec Kit scripts, commands, templates, branch naming rules, and generated paths. It must not manually bypass Spec Kit by creating arbitrary spec folders or files. The only allowed fixes after `analyze` are preparation-artifact fixes, not application-code implementation.
|
|
||||||
|
|
||||||
The intended workflow is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
roadmap.md + spec-candidates.md
|
|
||||||
→ select next best spec
|
|
||||||
→ run Spec Kit specify
|
|
||||||
→ run Spec Kit plan
|
|
||||||
→ run Spec Kit tasks
|
|
||||||
→ run Spec Kit analyze
|
|
||||||
→ fix preparation-artifact issues
|
|
||||||
→ explicit implementation step later
|
|
||||||
```
|
|
||||||
|
|
||||||
## When to Use
|
|
||||||
|
|
||||||
Use this skill when the user asks things like:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Nimm die nächste sinnvollste Spec aus roadmap und spec-candidates und führe specify, plan, tasks und analyze aus.
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
|
||||||
Wähle die nächste geeignete Spec und mach den Spec-Kit-Flow inklusive analyze in einem Rutsch.
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
|
||||||
Schau in roadmap.md und spec-candidates.md und starte daraus specify, plan, tasks und analyze.
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
|
||||||
Such die beste nächste Spec aus und bereite sie per GitHub Spec Kit vollständig vor.
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
|
||||||
Nimm angesichts Roadmap und Spec Candidates das sinnvollste nächste Thema, aber nicht implementieren.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hard Rules
|
|
||||||
|
|
||||||
- Work strictly repo-based.
|
|
||||||
- Use the repository's actual GitHub Spec Kit workflow.
|
|
||||||
- Do not manually invent spec numbers, branch names, or spec paths if Spec Kit provides a script or command for that.
|
|
||||||
- Do not manually create `spec.md`, `plan.md`, or `tasks.md` when the Spec Kit workflow can generate them.
|
|
||||||
- Do not bypass Spec Kit branch mechanics.
|
|
||||||
- Run `analyze` after `tasks` when the repository supports it.
|
|
||||||
- Fix only issues found in Spec Kit preparation artifacts and planning metadata.
|
|
||||||
- Do not treat analyze findings as permission to implement product code.
|
|
||||||
- If analyze reports implementation work as missing, record it in `tasks.md` instead of implementing it.
|
|
||||||
- Do not implement application code.
|
|
||||||
- Do not modify production code.
|
|
||||||
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task.
|
|
||||||
- Do not execute implementation commands.
|
|
||||||
- Do not run destructive commands.
|
|
||||||
- Do not invent roadmap priorities not supported by repository documents.
|
|
||||||
- Do not pick a spec only because it is listed first.
|
|
||||||
- Do not select broad platform rewrites when a smaller dependency-unlocking spec is more appropriate.
|
|
||||||
- Prefer specs that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers.
|
|
||||||
- Prefer small, reviewable, implementation-ready specs over large ambiguous themes.
|
|
||||||
- Preserve TenantPilot/TenantAtlas terminology.
|
|
||||||
- Follow the repository constitution and existing Spec Kit conventions.
|
|
||||||
- If repository truth conflicts with roadmap/candidate wording, keep repository truth and document the deviation.
|
|
||||||
- If no candidate is suitable, do not run Spec Kit commands and explain why.
|
|
||||||
|
|
||||||
## Required Repository Checks Before Selection
|
|
||||||
|
|
||||||
Before selecting the next spec, inspect:
|
|
||||||
|
|
||||||
1. `.specify/memory/constitution.md`
|
|
||||||
2. `.specify/templates/`
|
|
||||||
3. `.specify/scripts/`
|
|
||||||
4. existing Spec Kit command usage or repository instructions, if present
|
|
||||||
5. `specs/`
|
|
||||||
6. `docs/product/spec-candidates.md`
|
|
||||||
7. roadmap documents under `docs/product/`, especially `roadmap.md` if present
|
|
||||||
8. nearby existing specs related to top candidate areas
|
|
||||||
9. current branch and git status
|
|
||||||
10. application code only as needed to verify whether a candidate is already done, blocked, duplicated, or technically mis-scoped
|
|
||||||
|
|
||||||
Do not edit application code.
|
|
||||||
|
|
||||||
## Git and Branch Safety
|
|
||||||
|
|
||||||
Before running any Spec Kit command or script:
|
|
||||||
|
|
||||||
1. Check the current branch.
|
|
||||||
2. Check whether the working tree is clean.
|
|
||||||
3. If there are unrelated uncommitted changes, stop and report them. Do not continue.
|
|
||||||
4. If the working tree only contains user-intended planning edits for this operation, continue cautiously.
|
|
||||||
5. Let Spec Kit create or switch to the correct feature branch when that is how the repository workflow works.
|
|
||||||
6. Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
|
||||||
7. Do not overwrite existing specs.
|
|
||||||
|
|
||||||
If the repo requires an explicit branch creation script for `specify`, use that script rather than manually creating the branch.
|
|
||||||
|
|
||||||
## Candidate Selection Criteria
|
|
||||||
|
|
||||||
Evaluate candidate specs using these criteria.
|
|
||||||
|
|
||||||
### 1. Roadmap Fit
|
|
||||||
|
|
||||||
Prefer candidates that directly support the current roadmap sequence or unlock the next roadmap layer.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- governance foundations before advanced compliance views
|
|
||||||
- evidence/snapshot foundations before auditor packs
|
|
||||||
- control catalog foundations before CIS/NIS2 mappings
|
|
||||||
- decision/workflow surfaces before autonomous governance
|
|
||||||
- provider/platform boundary cleanup before multi-provider expansion
|
|
||||||
|
|
||||||
### 2. Foundation Value
|
|
||||||
|
|
||||||
Prefer candidates that strengthen reusable platform foundations:
|
|
||||||
|
|
||||||
- RBAC and workspace/tenant isolation
|
|
||||||
- auditability
|
|
||||||
- evidence and snapshot truth
|
|
||||||
- operation observability
|
|
||||||
- provider boundary neutrality
|
|
||||||
- canonical vocabulary
|
|
||||||
- baseline/control/finding semantics
|
|
||||||
- enterprise detail-page or decision-surface patterns
|
|
||||||
|
|
||||||
### 3. Dependency Unblocking
|
|
||||||
|
|
||||||
Prefer specs that unblock multiple later candidates.
|
|
||||||
|
|
||||||
A good next spec should usually make future specs smaller, safer, or more consistent.
|
|
||||||
|
|
||||||
### 4. Scope Size
|
|
||||||
|
|
||||||
Prefer a candidate that can be implemented as a narrow, testable slice.
|
|
||||||
|
|
||||||
Avoid selecting:
|
|
||||||
|
|
||||||
- broad platform rewrites
|
|
||||||
- vague product themes
|
|
||||||
- multi-feature bundles
|
|
||||||
- speculative future-provider frameworks
|
|
||||||
- large UX redesigns without a clear first slice
|
|
||||||
|
|
||||||
### 5. Repo Readiness
|
|
||||||
|
|
||||||
Prefer candidates where the repository already has enough structure to implement the next slice safely.
|
|
||||||
|
|
||||||
Check whether related models, services, UI pages, tests, or concepts already exist.
|
|
||||||
|
|
||||||
### 6. Risk Reduction
|
|
||||||
|
|
||||||
Prefer candidates that reduce current architectural or product risk:
|
|
||||||
|
|
||||||
- legacy dual-world semantics
|
|
||||||
- unclear truth ownership
|
|
||||||
- inconsistent operator UX
|
|
||||||
- missing audit/evidence boundaries
|
|
||||||
- repeated manual workflow friction
|
|
||||||
- false-positive calmness in governance surfaces
|
|
||||||
|
|
||||||
### 7. User/Product Value
|
|
||||||
|
|
||||||
Prefer specs that produce visible operator value or make the platform more sellable without creating heavy scope.
|
|
||||||
|
|
||||||
## Required Selection Output Before Spec Kit Execution
|
|
||||||
|
|
||||||
Before running the Spec Kit flow, identify:
|
|
||||||
|
|
||||||
- selected candidate title
|
|
||||||
- source location in roadmap/spec-candidates
|
|
||||||
- why it was selected
|
|
||||||
- why close alternatives were deferred
|
|
||||||
- roadmap relationship
|
|
||||||
- smallest viable implementation slice
|
|
||||||
- proposed concise feature description to feed into `specify`
|
|
||||||
|
|
||||||
The feature description must be product- and behavior-oriented. It should not be a low-level implementation plan.
|
|
||||||
|
|
||||||
## Spec Kit Execution Flow
|
|
||||||
|
|
||||||
After selecting the candidate, execute the real repository Spec Kit preparation sequence, including analysis and preparation-artifact fixes.
|
|
||||||
|
|
||||||
### Step 1: Determine the repository's Spec Kit command pattern
|
|
||||||
|
|
||||||
Inspect repository instructions and scripts to identify how this repo expects Spec Kit to be run.
|
|
||||||
|
|
||||||
Common locations to inspect:
|
|
||||||
|
|
||||||
```text
|
|
||||||
.specify/scripts/
|
|
||||||
.specify/templates/
|
|
||||||
.specify/memory/constitution.md
|
|
||||||
.github/prompts/
|
|
||||||
.github/skills/
|
|
||||||
README.md
|
|
||||||
specs/
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the repo-specific mechanism if present.
|
|
||||||
|
|
||||||
### Step 2: Run `specify`
|
|
||||||
|
|
||||||
Run the repository's `specify` flow using the selected candidate and the smallest viable slice.
|
|
||||||
|
|
||||||
The `specify` input should include:
|
|
||||||
|
|
||||||
- selected candidate title
|
|
||||||
- problem statement
|
|
||||||
- operator/user value
|
|
||||||
- roadmap relationship
|
|
||||||
- out-of-scope boundaries
|
|
||||||
- key acceptance criteria
|
|
||||||
- important enterprise constraints
|
|
||||||
|
|
||||||
Let Spec Kit create the correct branch and spec location if that is the repo's configured behavior.
|
|
||||||
|
|
||||||
### Step 3: Run `plan`
|
|
||||||
|
|
||||||
Run the repository's `plan` flow for the generated spec.
|
|
||||||
|
|
||||||
The `plan` input should keep the scope tight and should require repo-based alignment with:
|
|
||||||
|
|
||||||
- constitution
|
|
||||||
- existing architecture
|
|
||||||
- workspace/tenant isolation
|
|
||||||
- RBAC
|
|
||||||
- OperationRun/observability where relevant
|
|
||||||
- evidence/snapshot/truth semantics where relevant
|
|
||||||
- Filament/Livewire conventions where relevant
|
|
||||||
- test strategy
|
|
||||||
|
|
||||||
### Step 4: Run `tasks`
|
|
||||||
|
|
||||||
Run the repository's `tasks` flow for the generated plan.
|
|
||||||
|
|
||||||
The generated tasks must be:
|
|
||||||
|
|
||||||
- ordered
|
|
||||||
- small
|
|
||||||
- testable
|
|
||||||
- grouped by phase
|
|
||||||
- limited to the selected scope
|
|
||||||
- suitable for later manual analysis before implementation
|
|
||||||
|
|
||||||
### Step 5: Run `analyze`
|
|
||||||
|
|
||||||
Run the repository's `analyze` flow against the generated Spec Kit artifacts.
|
|
||||||
|
|
||||||
Analyze must check:
|
|
||||||
|
|
||||||
- consistency between `spec.md`, `plan.md`, and `tasks.md`
|
|
||||||
- constitution alignment
|
|
||||||
- roadmap alignment
|
|
||||||
- whether the selected candidate was narrowed safely
|
|
||||||
- whether tasks are complete enough for implementation
|
|
||||||
- whether tasks accidentally require scope not described in the spec
|
|
||||||
- whether plan details conflict with repository architecture or terminology
|
|
||||||
- whether implementation risks are documented instead of silently ignored
|
|
||||||
|
|
||||||
Do not use analyze as a trigger to implement application code.
|
|
||||||
|
|
||||||
### Step 6: Fix preparation-artifact issues only
|
|
||||||
|
|
||||||
If analyze finds issues, fix only Spec Kit preparation artifacts such as:
|
|
||||||
|
|
||||||
- `spec.md`
|
|
||||||
- `plan.md`
|
|
||||||
- `tasks.md`
|
|
||||||
- generated Spec Kit metadata files, if the repository uses them
|
|
||||||
|
|
||||||
Allowed fixes include:
|
|
||||||
|
|
||||||
- clarify requirements
|
|
||||||
- tighten scope
|
|
||||||
- move out-of-scope work into follow-up candidates
|
|
||||||
- correct terminology
|
|
||||||
- add missing tasks
|
|
||||||
- remove tasks not backed by the spec
|
|
||||||
- align plan language with repository architecture
|
|
||||||
- add missing acceptance criteria or validation tasks
|
|
||||||
|
|
||||||
Forbidden fixes include:
|
|
||||||
|
|
||||||
- modifying application code
|
|
||||||
- creating migrations
|
|
||||||
- editing models, services, jobs, policies, Filament resources, Livewire components, tests, or commands
|
|
||||||
- running implementation or test-fix loops
|
|
||||||
- changing runtime behavior
|
|
||||||
|
|
||||||
### Step 7: Stop
|
|
||||||
|
|
||||||
After `analyze` has passed or preparation-artifact issues have been fixed, stop.
|
|
||||||
|
|
||||||
Do not implement.
|
|
||||||
Do not modify application code.
|
|
||||||
Do not run implementation tests unless the repository's Spec Kit preparation command requires a non-destructive validation.
|
|
||||||
|
|
||||||
## Failure Handling
|
|
||||||
|
|
||||||
If a Spec Kit command or analyze phase fails:
|
|
||||||
|
|
||||||
1. Stop immediately.
|
|
||||||
2. Report the failing command or phase.
|
|
||||||
3. Summarize the error.
|
|
||||||
4. Do not attempt implementation as a workaround.
|
|
||||||
5. Suggest the smallest safe next action.
|
|
||||||
|
|
||||||
If the branch or working tree state is unsafe:
|
|
||||||
|
|
||||||
1. Stop before running Spec Kit commands.
|
|
||||||
2. Report the current branch and relevant uncommitted files.
|
|
||||||
3. Ask the user to commit, stash, or move to a clean worktree.
|
|
||||||
|
|
||||||
## Final Response Requirements
|
|
||||||
|
|
||||||
After the Spec Kit preparation flow completes, respond with:
|
|
||||||
|
|
||||||
1. Selected candidate
|
|
||||||
2. Why this candidate was selected
|
|
||||||
3. Why close alternatives were deferred
|
|
||||||
4. Current branch after Spec Kit execution
|
|
||||||
5. Generated spec path
|
|
||||||
6. Files created or updated by Spec Kit
|
|
||||||
7. Analyze result summary
|
|
||||||
8. Preparation-artifact fixes applied after analyze
|
|
||||||
9. Assumptions made
|
|
||||||
10. Open questions, if any
|
|
||||||
11. Recommended next implementation prompt
|
|
||||||
12. Explicit statement that no application implementation was performed
|
|
||||||
|
|
||||||
Keep the response concise, but include enough detail for the user to continue immediately.
|
|
||||||
|
|
||||||
## Required Next Implementation Prompt
|
|
||||||
|
|
||||||
Always provide a ready-to-copy implementation prompt like this, adapted to the generated spec branch/path, but only after analyze has passed or preparation-artifact issues have been fixed:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
Du bist ein Senior Staff Software Engineer für TenantPilot/TenantAtlas.
|
|
||||||
|
|
||||||
Implementiere die vorbereitete Spec `<spec-branch-or-spec-path>` streng anhand von `tasks.md`.
|
|
||||||
|
|
||||||
Wichtig:
|
|
||||||
- Arbeite task-sequenziell.
|
|
||||||
- Ändere nur Dateien, die für die jeweilige Task notwendig sind.
|
|
||||||
- Halte dich an `spec.md`, `plan.md`, `tasks.md` und die Constitution.
|
|
||||||
- Keine Scope-Erweiterung.
|
|
||||||
- Keine Opportunistic Refactors.
|
|
||||||
- Führe passende Tests nach sinnvollen Task-Gruppen aus.
|
|
||||||
- Wenn eine Task unklar oder falsch ist, stoppe und dokumentiere den Konflikt statt frei zu improvisieren.
|
|
||||||
- Am Ende liefere geänderte Dateien, Teststatus, offene Risiken und nicht erledigte Tasks.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Example Invocation
|
|
||||||
|
|
||||||
User:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Nutze den Skill spec-kit-next-best-one-shot.
|
|
||||||
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec.
|
|
||||||
Führe danach GitHub Spec Kit specify, plan, tasks und analyze in einem Rutsch aus.
|
|
||||||
Behebe alle analyze-Issues in den Spec-Kit-Artefakten.
|
|
||||||
Keine Application-Implementierung.
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected behavior:
|
|
||||||
|
|
||||||
1. Inspect constitution, Spec Kit scripts/templates, specs, roadmap, and spec candidates.
|
|
||||||
2. Check branch and working tree safety.
|
|
||||||
3. Compare candidate suitability.
|
|
||||||
4. Select the next best candidate.
|
|
||||||
5. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
|
|
||||||
6. Run the repository's real Spec Kit `plan` flow.
|
|
||||||
7. Run the repository's real Spec Kit `tasks` flow.
|
|
||||||
8. Run the repository's real Spec Kit `analyze` flow.
|
|
||||||
9. Fix analyze issues only in Spec Kit preparation artifacts.
|
|
||||||
10. Stop before application implementation.
|
|
||||||
11. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, and next implementation prompt.
|
|
||||||
```
|
|
||||||
562
.github/skills/spec-kit-next-best-prep/SKILL.md
vendored
Normal file
562
.github/skills/spec-kit-next-best-prep/SKILL.md
vendored
Normal file
@ -0,0 +1,562 @@
|
|||||||
|
---
|
||||||
|
name: spec-kit-next-best-prep
|
||||||
|
description: Select the next suitable TenantPilot/TenantAtlas spec candidate from roadmap/spec-candidates, run the repository's Spec Kit preparation flow, create or update spec.md/plan.md/tasks.md, run preparation analysis, fix preparation-artifact issues only, and stop before application implementation.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: Spec Kit Next-Best Preparation
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Use this skill to prepare the next implementation-ready Spec Kit package for TenantPilot/TenantAtlas without implementing application code.
|
||||||
|
|
||||||
|
This skill supports preparation only:
|
||||||
|
|
||||||
|
1. Select or scope the next suitable feature from roadmap/spec-candidates.
|
||||||
|
2. Run the repository's real Spec Kit preparation workflow where available.
|
||||||
|
3. Create or update `spec.md`, `plan.md`, and `tasks.md`.
|
||||||
|
4. Run preparation `analyze` when supported.
|
||||||
|
5. Fix preparation-artifact issues only.
|
||||||
|
6. Evaluate preparation quality gates.
|
||||||
|
7. Stop before application implementation.
|
||||||
|
|
||||||
|
The intended workflow is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
roadmap / spec-candidates / feature idea
|
||||||
|
→ inspect repo truth, constitution, roadmap, spec candidates, existing specs, and relevant code
|
||||||
|
→ select the next suitable candidate or scope the provided idea
|
||||||
|
→ run Spec Kit specify/plan/tasks/analyze where available
|
||||||
|
→ create or update spec.md + plan.md + tasks.md
|
||||||
|
→ fix preparation-artifact issues only
|
||||||
|
→ evaluate Candidate Selection Gate and Spec Readiness Gate
|
||||||
|
→ final preparation report
|
||||||
|
→ explicit implementation step later
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use this skill when the user asks to:
|
||||||
|
|
||||||
|
- select the next best spec candidate from `docs/product/spec-candidates.md` and roadmap sources
|
||||||
|
- turn a feature idea, roadmap item, or candidate into `spec.md`, `plan.md`, and `tasks.md`
|
||||||
|
- prepare Spec Kit artifacts in one pass
|
||||||
|
- run specify/plan/tasks/analyze without implementation
|
||||||
|
- fix preparation analysis issues in Spec Kit artifacts only
|
||||||
|
- prepare a feature package for a later implementation skill
|
||||||
|
|
||||||
|
Typical user prompts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nimm den nächsten sinnvollen Spec Candidate aus Roadmap/spec-candidates und mach spec, plan und tasks.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Mach daraus spec, plan und tasks in einem Rutsch, aber noch nicht implementieren.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec und führe specify, plan, tasks und analyze aus.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Behebe alle analyze-Issues in den Spec-Kit-Artefakten. Keine Application-Implementierung.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hard Rules
|
||||||
|
|
||||||
|
- Work strictly repo-based.
|
||||||
|
- This is a preparation-only skill.
|
||||||
|
- Do not implement application code.
|
||||||
|
- Do not modify production code.
|
||||||
|
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, routes, views, tests, or runtime behavior.
|
||||||
|
- Use the repository's actual Spec Kit workflow, scripts, templates, branch naming rules, and generated paths when available.
|
||||||
|
- Do not manually invent spec numbers, branch names, or spec paths if Spec Kit provides a script or command for that.
|
||||||
|
- Do not bypass Spec Kit branch mechanics.
|
||||||
|
- Create or update only Spec Kit preparation artifacts unless repository conventions require additional documentation artifacts.
|
||||||
|
- Do not expand scope beyond the selected feature, `spec.md`, `plan.md`, and `tasks.md`.
|
||||||
|
- Do not silently add roadmap features, adjacent UX rewrites, speculative architecture, or unrelated refactors.
|
||||||
|
- Follow the repository constitution and existing Spec Kit conventions.
|
||||||
|
- Preserve TenantPilot/TenantAtlas terminology.
|
||||||
|
- Prefer small, reviewable, implementation-ready specs over broad rewrites.
|
||||||
|
- Treat repository truth as authoritative over assumptions.
|
||||||
|
- If repository truth conflicts with the user-provided draft or candidate wording, keep repository truth and document the deviation.
|
||||||
|
- Fix only confirmed preparation-artifact findings from Spec Kit preparation analysis.
|
||||||
|
- Do not leave preparation findings open silently. If they are not fixed, document exactly why.
|
||||||
|
- Do not run destructive commands.
|
||||||
|
- Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||||
|
- Do not overwrite existing specs.
|
||||||
|
- Do not move from preparation to an implementation step inside this skill.
|
||||||
|
|
||||||
|
## Required Inputs
|
||||||
|
|
||||||
|
The user should provide at least one of:
|
||||||
|
|
||||||
|
- feature title and short goal
|
||||||
|
- full spec candidate
|
||||||
|
- roadmap item
|
||||||
|
- rough problem statement
|
||||||
|
- UX or architecture improvement idea
|
||||||
|
- instruction to choose the next best candidate from roadmap/spec-candidates
|
||||||
|
|
||||||
|
If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions.
|
||||||
|
|
||||||
|
If no suitable candidate can be selected safely, stop and report why.
|
||||||
|
|
||||||
|
## Required Repository Checks
|
||||||
|
|
||||||
|
Always check:
|
||||||
|
|
||||||
|
1. `.specify/memory/constitution.md`
|
||||||
|
2. `.specify/templates/`
|
||||||
|
3. `.specify/scripts/`
|
||||||
|
4. existing Spec Kit command usage or repository instructions, if present
|
||||||
|
5. current branch and git status
|
||||||
|
6. `specs/`
|
||||||
|
7. `docs/product/spec-candidates.md`
|
||||||
|
8. relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present
|
||||||
|
9. nearby existing specs with related terminology or scope
|
||||||
|
10. application code only as needed to avoid wrong naming, wrong architecture, duplicate concepts, impossible tasks, duplicated specs, or already-completed candidates
|
||||||
|
|
||||||
|
Do not edit application code.
|
||||||
|
|
||||||
|
## Git and Branch Safety
|
||||||
|
|
||||||
|
Before running any Spec Kit command:
|
||||||
|
|
||||||
|
1. Check the current branch.
|
||||||
|
2. Check whether the working tree is clean.
|
||||||
|
3. If there are unrelated uncommitted changes, stop and report them. Do not continue.
|
||||||
|
4. If the working tree only contains user-intended planning edits for this operation, continue cautiously.
|
||||||
|
5. Let Spec Kit create or switch to the correct feature branch when that is how the repository workflow works.
|
||||||
|
6. Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||||
|
7. Do not overwrite existing specs.
|
||||||
|
|
||||||
|
If the repo requires an explicit branch creation script for `specify`, use that script rather than manually creating the branch.
|
||||||
|
|
||||||
|
## Quality Gates
|
||||||
|
|
||||||
|
### Gate 1: Candidate Selection Gate
|
||||||
|
|
||||||
|
Required before creating a new spec from roadmap/spec-candidates.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- The selected candidate exists in roadmap/spec-candidate material or is directly provided by the user.
|
||||||
|
- The selected candidate is not already covered by an existing active or completed spec.
|
||||||
|
- The selected candidate aligns with current roadmap priorities or explicitly documented product direction.
|
||||||
|
- The candidate can be scoped as a small, reviewable, implementation-ready slice.
|
||||||
|
- Major adjacent concerns are listed as follow-up candidates instead of being hidden inside the primary scope.
|
||||||
|
|
||||||
|
Fail behavior:
|
||||||
|
|
||||||
|
- If no candidate satisfies the gate, stop and report the top candidates plus the reason none is ready.
|
||||||
|
- Do not invent a new roadmap direction to force progress.
|
||||||
|
|
||||||
|
### Gate 2: Spec Readiness Gate
|
||||||
|
|
||||||
|
Required before reporting that the package is ready for implementation.
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
|
||||||
|
- `spec.md`, `plan.md`, and `tasks.md` exist.
|
||||||
|
- The spec has clear problem statement, user value, functional requirements, out-of-scope boundaries, acceptance criteria, assumptions, and risks.
|
||||||
|
- The plan identifies likely affected repo surfaces and does not contradict repository architecture.
|
||||||
|
- The tasks are small, ordered, verifiable, and include test/validation tasks.
|
||||||
|
- RBAC, workspace/tenant isolation, auditability, OperationRun semantics, evidence/result-truth, and UX requirements are addressed where relevant.
|
||||||
|
- No open question blocks safe implementation.
|
||||||
|
- The scope is small enough for a bounded implementation loop in a later implementation skill.
|
||||||
|
- Required checklist artifacts exist when the constitution requires them.
|
||||||
|
|
||||||
|
Fail behavior:
|
||||||
|
|
||||||
|
- Fix preparation-artifact issues when they are safe and bounded.
|
||||||
|
- If readiness cannot be achieved without implementation or unresolved product decisions, stop and report the gap.
|
||||||
|
- Do not compensate for an unclear spec by inventing implementation scope.
|
||||||
|
|
||||||
|
## Candidate Selection Rules
|
||||||
|
|
||||||
|
When the user asks for the next best spec from roadmap/spec-candidates:
|
||||||
|
|
||||||
|
- Read `docs/product/spec-candidates.md`.
|
||||||
|
- Read relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present.
|
||||||
|
- Check existing specs to avoid duplicates.
|
||||||
|
- Prefer candidates that align with current roadmap priorities, platform foundations, enterprise UX, RBAC/isolation, auditability, observability, and governance workflow maturity.
|
||||||
|
- Prefer candidates that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers.
|
||||||
|
- Prefer small, implementation-ready slices over broad platform rewrites.
|
||||||
|
- If multiple candidates are plausible, choose one primary candidate and document why it was selected.
|
||||||
|
- Add non-selected relevant candidates as follow-up spec candidates, not hidden scope.
|
||||||
|
- Do not invent a candidate if existing roadmap/spec-candidate material provides a suitable one.
|
||||||
|
- Do not pick a spec only because it is listed first.
|
||||||
|
- Evaluate the Candidate Selection Gate before creating the spec directory.
|
||||||
|
|
||||||
|
Evaluate candidates using these criteria:
|
||||||
|
|
||||||
|
1. **Roadmap Fit**: Does it support the current roadmap sequence or unlock the next roadmap layer?
|
||||||
|
2. **Foundation Value**: Does it strengthen reusable platform foundations such as RBAC, isolation, auditability, evidence, OperationRun observability, provider boundaries, vocabulary, baseline/control/finding semantics, or enterprise UX patterns?
|
||||||
|
3. **Dependency Unblocking**: Does it make future specs smaller, safer, or more consistent?
|
||||||
|
4. **Scope Size**: Can it be implemented as a narrow, testable slice?
|
||||||
|
5. **Repo Readiness**: Does the repo already have enough structure to implement the next slice safely?
|
||||||
|
6. **Risk Reduction**: Does it reduce current architectural or product risk?
|
||||||
|
7. **User/Product Value**: Does it produce visible operator value or make the platform more sellable without heavy scope?
|
||||||
|
|
||||||
|
## Required Selection Output Before Spec Kit Execution
|
||||||
|
|
||||||
|
Before running the Spec Kit flow, identify:
|
||||||
|
|
||||||
|
- selected candidate title
|
||||||
|
- source location in roadmap/spec-candidates
|
||||||
|
- why it was selected
|
||||||
|
- why close alternatives were deferred
|
||||||
|
- roadmap relationship
|
||||||
|
- smallest viable implementation slice
|
||||||
|
- proposed concise feature description to feed into `specify`
|
||||||
|
|
||||||
|
The feature description must be product- and behavior-oriented. It should not be a low-level implementation plan.
|
||||||
|
|
||||||
|
## Spec Kit Preparation Flow
|
||||||
|
|
||||||
|
### Step 1: Determine the repository's Spec Kit command pattern
|
||||||
|
|
||||||
|
Inspect repository instructions and scripts to identify how this repo expects Spec Kit to be run.
|
||||||
|
|
||||||
|
Common locations to inspect:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.specify/scripts/
|
||||||
|
.specify/templates/
|
||||||
|
.specify/memory/constitution.md
|
||||||
|
.github/prompts/
|
||||||
|
.github/skills/
|
||||||
|
README.md
|
||||||
|
specs/
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the repo-specific mechanism if present.
|
||||||
|
|
||||||
|
### Step 2: Run `specify`
|
||||||
|
|
||||||
|
Run the repository's `specify` flow using the selected candidate and the smallest viable slice.
|
||||||
|
|
||||||
|
The `specify` input should include:
|
||||||
|
|
||||||
|
- selected candidate title
|
||||||
|
- problem statement
|
||||||
|
- operator/user value
|
||||||
|
- roadmap relationship
|
||||||
|
- out-of-scope boundaries
|
||||||
|
- key acceptance criteria
|
||||||
|
- important enterprise constraints
|
||||||
|
|
||||||
|
Let Spec Kit create the correct branch and spec location if that is the repo's configured behavior.
|
||||||
|
|
||||||
|
### Step 3: Run `plan`
|
||||||
|
|
||||||
|
Run the repository's `plan` flow for the generated spec.
|
||||||
|
|
||||||
|
The `plan` input should keep the scope tight and should require repo-based alignment with:
|
||||||
|
|
||||||
|
- constitution
|
||||||
|
- existing architecture
|
||||||
|
- workspace/tenant isolation
|
||||||
|
- RBAC
|
||||||
|
- OperationRun/observability where relevant
|
||||||
|
- evidence/snapshot/truth semantics where relevant
|
||||||
|
- Filament/Livewire conventions where relevant
|
||||||
|
- test strategy
|
||||||
|
|
||||||
|
### Step 4: Run `tasks`
|
||||||
|
|
||||||
|
Run the repository's `tasks` flow for the generated plan.
|
||||||
|
|
||||||
|
The generated tasks must be:
|
||||||
|
|
||||||
|
- ordered
|
||||||
|
- small
|
||||||
|
- testable
|
||||||
|
- grouped by phase
|
||||||
|
- limited to the selected scope
|
||||||
|
- suitable for later implementation or manual analysis before implementation
|
||||||
|
|
||||||
|
### Step 5: Run preparation `analyze`
|
||||||
|
|
||||||
|
Run the repository's `analyze` flow against the generated Spec Kit artifacts when the repository supports it.
|
||||||
|
|
||||||
|
Analyze must check:
|
||||||
|
|
||||||
|
- consistency between `spec.md`, `plan.md`, and `tasks.md`
|
||||||
|
- constitution alignment
|
||||||
|
- roadmap alignment
|
||||||
|
- whether the selected candidate was narrowed safely
|
||||||
|
- whether tasks are complete enough for implementation
|
||||||
|
- whether tasks accidentally require scope not described in the spec
|
||||||
|
- whether plan details conflict with repository architecture or terminology
|
||||||
|
- whether implementation risks are documented instead of silently ignored
|
||||||
|
|
||||||
|
Do not use analyze as a trigger to implement application code.
|
||||||
|
|
||||||
|
### Step 6: Fix preparation-artifact issues only
|
||||||
|
|
||||||
|
If preparation analyze finds issues, fix only Spec Kit preparation artifacts such as:
|
||||||
|
|
||||||
|
- `spec.md`
|
||||||
|
- `plan.md`
|
||||||
|
- `tasks.md`
|
||||||
|
- `checklists/requirements.md` or other generated Spec Kit metadata files, if the repository uses them
|
||||||
|
|
||||||
|
Allowed fixes include:
|
||||||
|
|
||||||
|
- clarify requirements
|
||||||
|
- tighten scope
|
||||||
|
- move out-of-scope work into follow-up candidates
|
||||||
|
- correct terminology
|
||||||
|
- add missing tasks
|
||||||
|
- remove tasks not backed by the spec
|
||||||
|
- align plan language with repository architecture
|
||||||
|
- add missing acceptance criteria or validation tasks
|
||||||
|
- add missing checklist artifacts required by the constitution
|
||||||
|
|
||||||
|
Forbidden fixes include:
|
||||||
|
|
||||||
|
- modifying application code
|
||||||
|
- creating migrations
|
||||||
|
- editing models, services, jobs, policies, Filament resources, Livewire components, tests, commands, routes, or views
|
||||||
|
- running implementation or test-fix loops
|
||||||
|
- changing runtime behavior
|
||||||
|
|
||||||
|
### Step 7: Evaluate the Spec Readiness Gate
|
||||||
|
|
||||||
|
After preparation analyze has passed or preparation-artifact issues have been fixed, evaluate the Spec Readiness Gate.
|
||||||
|
|
||||||
|
Stop after this gate and do not implement.
|
||||||
|
|
||||||
|
## Spec Directory Rules
|
||||||
|
|
||||||
|
When creating a new spec directory, use the repository's Spec Kit-generated directory or path.
|
||||||
|
|
||||||
|
If the repository does not provide a command for spec setup, use the next valid spec number and a kebab-case slug:
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/<number>-<slug>/
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact number must be derived from the current repository state and existing numbering conventions.
|
||||||
|
|
||||||
|
Create or update preparation artifacts inside the selected spec directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/<number>-<slug>/spec.md
|
||||||
|
specs/<number>-<slug>/plan.md
|
||||||
|
specs/<number>-<slug>/tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions.
|
||||||
|
|
||||||
|
## `spec.md` Requirements
|
||||||
|
|
||||||
|
The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness.
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- Feature title
|
||||||
|
- Problem statement
|
||||||
|
- Business/product value
|
||||||
|
- Primary users/operators
|
||||||
|
- User stories
|
||||||
|
- Functional requirements
|
||||||
|
- Non-functional requirements
|
||||||
|
- UX requirements
|
||||||
|
- RBAC/security requirements
|
||||||
|
- Auditability/observability requirements
|
||||||
|
- Data/truth-source requirements where relevant
|
||||||
|
- Out of scope
|
||||||
|
- Acceptance criteria
|
||||||
|
- Success criteria
|
||||||
|
- Risks
|
||||||
|
- Assumptions
|
||||||
|
- Open questions
|
||||||
|
|
||||||
|
TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles:
|
||||||
|
|
||||||
|
- workspace/tenant isolation
|
||||||
|
- capability-first RBAC
|
||||||
|
- auditability
|
||||||
|
- operation/result truth separation
|
||||||
|
- source-of-truth clarity
|
||||||
|
- calm enterprise operator UX
|
||||||
|
- progressive disclosure where useful
|
||||||
|
- no false positive calmness
|
||||||
|
|
||||||
|
## `plan.md` Requirements
|
||||||
|
|
||||||
|
The plan must be repo-aware and implementation-oriented, but it must not make code changes by itself.
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- Technical approach
|
||||||
|
- Existing repository surfaces likely affected
|
||||||
|
- Domain/model implications
|
||||||
|
- UI/Filament implications
|
||||||
|
- Livewire implications where relevant
|
||||||
|
- OperationRun/monitoring implications where relevant
|
||||||
|
- RBAC/policy implications
|
||||||
|
- Audit/logging/evidence implications where relevant
|
||||||
|
- Data/migration implications where relevant
|
||||||
|
- Test strategy
|
||||||
|
- Rollout considerations
|
||||||
|
- Risk controls
|
||||||
|
- Implementation phases
|
||||||
|
|
||||||
|
The plan should clearly distinguish where relevant:
|
||||||
|
|
||||||
|
- execution truth
|
||||||
|
- artifact truth
|
||||||
|
- backup/snapshot truth
|
||||||
|
- recovery/evidence truth
|
||||||
|
- operator next action
|
||||||
|
|
||||||
|
## `tasks.md` Requirements
|
||||||
|
|
||||||
|
Tasks must be ordered, small, and verifiable.
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- checkbox tasks
|
||||||
|
- phase grouping
|
||||||
|
- tests before or alongside implementation tasks where practical
|
||||||
|
- final validation tasks
|
||||||
|
- documentation/update tasks if needed
|
||||||
|
- explicit non-goals where useful
|
||||||
|
|
||||||
|
Avoid vague tasks such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Clean up code
|
||||||
|
Refactor UI
|
||||||
|
Improve performance
|
||||||
|
Make it enterprise-ready
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer concrete tasks such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- [ ] Add a feature test covering workspace isolation for <specific behavior>.
|
||||||
|
- [ ] Update <specific Filament page/resource> to display <specific state>.
|
||||||
|
- [ ] Add policy coverage for <specific capability>.
|
||||||
|
```
|
||||||
|
|
||||||
|
If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths.
|
||||||
|
|
||||||
|
## Preparation Scope Control
|
||||||
|
|
||||||
|
If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section.
|
||||||
|
|
||||||
|
Examples of follow-up candidates:
|
||||||
|
|
||||||
|
- assigned findings
|
||||||
|
- pending approvals
|
||||||
|
- personal work queue
|
||||||
|
- notification delivery settings
|
||||||
|
- evidence pack export hardening
|
||||||
|
- operation monitoring refinements
|
||||||
|
- autonomous governance decision surfaces
|
||||||
|
|
||||||
|
Do not force all follow-up candidates into the primary spec.
|
||||||
|
|
||||||
|
## Failure Handling
|
||||||
|
|
||||||
|
If a Spec Kit command or preparation analyze phase fails:
|
||||||
|
|
||||||
|
1. Stop at the relevant gate.
|
||||||
|
2. Report the failing command or phase.
|
||||||
|
3. Summarize the error.
|
||||||
|
4. Do not attempt implementation as a workaround.
|
||||||
|
5. Suggest the smallest safe next action.
|
||||||
|
|
||||||
|
If the branch or working tree state is unsafe:
|
||||||
|
|
||||||
|
1. Stop before running Spec Kit commands.
|
||||||
|
2. Report the current branch and relevant uncommitted files.
|
||||||
|
3. Ask the user to commit, stash, or move to a clean worktree.
|
||||||
|
|
||||||
|
## Final Response Requirements
|
||||||
|
|
||||||
|
Respond with:
|
||||||
|
|
||||||
|
1. Selected candidate and why it was chosen
|
||||||
|
2. Why close alternatives were deferred
|
||||||
|
3. Current branch after Spec Kit execution, if changed
|
||||||
|
4. Generated spec path
|
||||||
|
5. Files created or updated by Spec Kit
|
||||||
|
6. Preparation analyze result summary
|
||||||
|
7. Preparation-artifact fixes applied after analyze
|
||||||
|
8. Assumptions made
|
||||||
|
9. Open questions, if any
|
||||||
|
10. Candidate Selection Gate result
|
||||||
|
11. Spec Readiness Gate result
|
||||||
|
12. Recommended next implementation prompt
|
||||||
|
13. Explicit statement that no application implementation was performed
|
||||||
|
|
||||||
|
Keep the final response concise, but include enough detail for the user to continue immediately.
|
||||||
|
|
||||||
|
## Manual Review and Next-Step Prompts
|
||||||
|
|
||||||
|
Provide a ready-to-copy manual artifact review prompt like this, adapted to the generated spec branch/path:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
|
||||||
|
|
||||||
|
Analysiere die neu erstellte Spec `<spec-branch-or-spec-path>` streng repo-basiert.
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind.
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
- Keine Implementierung.
|
||||||
|
- Keine Codeänderungen.
|
||||||
|
- Keine Scope-Erweiterung.
|
||||||
|
- Prüfe nur gegen Repo-Wahrheit.
|
||||||
|
- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs.
|
||||||
|
- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor.
|
||||||
|
- Wenn alles passt, gib eine klare Implementierungsfreigabe.
|
||||||
|
```
|
||||||
|
|
||||||
|
Also provide a ready-to-copy implementation prompt for the separate implementation skill after analyze has passed or preparation-artifact issues have been fixed:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
/spec-kit-implementation-loop
|
||||||
|
|
||||||
|
Implementiere die vorbereitete Spec `<spec-branch-or-spec-path>` streng anhand von `tasks.md`.
|
||||||
|
|
||||||
|
Danach Tests ausführen, Browser Smoke Test falls UI/user-facing betroffen ist, Post-Implementation Analyse durchführen und alle bestätigten In-Scope Findings unabhängig von Severity beheben, wenn safe und bounded.
|
||||||
|
|
||||||
|
Wiederhole test + browser smoke + analysis + fix bis keine In-Scope Findings mehr offen sind oder eine Stop Condition greift.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Invocation
|
||||||
|
|
||||||
|
User:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nutze den Skill spec-kit-next-best-prep.
|
||||||
|
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec.
|
||||||
|
Führe danach GitHub Spec Kit specify, plan, tasks und analyze in einem Rutsch aus.
|
||||||
|
Behebe alle analyze-Issues in den Spec-Kit-Artefakten.
|
||||||
|
Keine Application-Implementierung.
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
1. Inspect constitution, Spec Kit scripts/templates, specs, roadmap, and spec candidates.
|
||||||
|
2. Check branch and working tree safety.
|
||||||
|
3. Compare candidate suitability.
|
||||||
|
4. Select the next best candidate.
|
||||||
|
5. Evaluate the Candidate Selection Gate.
|
||||||
|
6. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
|
||||||
|
7. Run the repository's real Spec Kit `plan` flow.
|
||||||
|
8. Run the repository's real Spec Kit `tasks` flow.
|
||||||
|
9. Run the repository's real Spec Kit preparation `analyze` flow.
|
||||||
|
10. Fix analyze issues only in Spec Kit preparation artifacts.
|
||||||
|
11. Evaluate the Spec Readiness Gate.
|
||||||
|
12. Stop before application implementation.
|
||||||
|
13. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, gates, and next implementation prompt.
|
||||||
|
```
|
||||||
294
.github/skills/spec-kit-one-shot-prep/SKILL.md
vendored
294
.github/skills/spec-kit-one-shot-prep/SKILL.md
vendored
@ -1,294 +0,0 @@
|
|||||||
---
|
|
||||||
name: spec-kit-one-shot-prep
|
|
||||||
description: Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.
|
|
||||||
---
|
|
||||||
|
|
||||||
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
|
|
||||||
|
|
||||||
Define the functionality provided by this skill, including detailed instructions and examples
|
|
||||||
---
|
|
||||||
name: spec-kit-one-shot-prep
|
|
||||||
description: Create Spec Kit preparation artifacts in one pass for TenantPilot/TenantAtlas features: spec.md, plan.md, and tasks.md. Use for feature ideas, roadmap items, spec candidates, governance/platform improvements, UX improvements, cleanup candidates, and repo-based preparation before manual analysis or implementation. This skill must not implement application code.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Skill: Spec Kit One-Shot Preparation
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Use this skill to create a complete Spec Kit preparation package for a new TenantPilot/TenantAtlas feature in one pass:
|
|
||||||
|
|
||||||
1. `spec.md`
|
|
||||||
2. `plan.md`
|
|
||||||
3. `tasks.md`
|
|
||||||
|
|
||||||
This skill prepares implementation work, but it must not perform implementation.
|
|
||||||
|
|
||||||
The intended workflow is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
feature idea / roadmap item / spec candidate
|
|
||||||
→ one-shot spec + plan + tasks preparation
|
|
||||||
→ manual repo-based analysis/review
|
|
||||||
→ explicit implementation step later
|
|
||||||
```
|
|
||||||
|
|
||||||
## When to Use
|
|
||||||
|
|
||||||
Use this skill when the user asks to create or prepare Spec Kit artifacts from:
|
|
||||||
|
|
||||||
- a feature idea
|
|
||||||
- a spec candidate
|
|
||||||
- a roadmap item
|
|
||||||
- a product or UX requirement
|
|
||||||
- a governance/platform improvement
|
|
||||||
- an architecture cleanup candidate
|
|
||||||
- a refactoring preparation request
|
|
||||||
- a TenantPilot/TenantAtlas implementation idea that should first become a formal spec
|
|
||||||
|
|
||||||
Typical user prompts:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Mach daraus spec, plan und tasks in einem Rutsch.
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
|
||||||
Erstelle daraus eine neue Spec Kit Vorbereitung, aber noch nicht implementieren.
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
|
||||||
Nimm diesen spec candidate und bereite spec/plan/tasks vor.
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
|
||||||
Erzeuge die Spec Kit Artefakte, danach mache ich die Analyse manuell.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hard Rules
|
|
||||||
|
|
||||||
- Work strictly repo-based.
|
|
||||||
- Do not implement application code.
|
|
||||||
- Do not modify production code.
|
|
||||||
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task.
|
|
||||||
- Do not execute implementation commands.
|
|
||||||
- Do not run destructive commands.
|
|
||||||
- Do not expand scope beyond the provided feature idea.
|
|
||||||
- Do not invent architecture that conflicts with repository truth.
|
|
||||||
- Do not create broad platform rewrites when a smaller implementable spec is possible.
|
|
||||||
- Prefer small, reviewable, implementation-ready specs.
|
|
||||||
- Preserve TenantPilot/TenantAtlas terminology.
|
|
||||||
- Follow the repository constitution and existing Spec Kit conventions.
|
|
||||||
- If repository truth conflicts with the user-provided draft, keep repository truth and document the deviation.
|
|
||||||
- If the feature is too broad, split it into one primary spec and optional follow-up spec candidates.
|
|
||||||
|
|
||||||
## Required Inputs
|
|
||||||
|
|
||||||
The user should provide at least one of:
|
|
||||||
|
|
||||||
- feature title and short goal
|
|
||||||
- full spec candidate
|
|
||||||
- roadmap item
|
|
||||||
- rough problem statement
|
|
||||||
- UX or architecture improvement idea
|
|
||||||
|
|
||||||
If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions. Do not block on clarification unless the request is impossible to scope safely.
|
|
||||||
|
|
||||||
## Required Repository Checks
|
|
||||||
|
|
||||||
Before creating or updating Spec Kit artifacts, inspect the relevant repository sources.
|
|
||||||
|
|
||||||
Always check:
|
|
||||||
|
|
||||||
1. `.specify/memory/constitution.md`
|
|
||||||
2. `.specify/templates/`
|
|
||||||
3. `specs/`
|
|
||||||
4. `docs/product/spec-candidates.md`
|
|
||||||
5. relevant roadmap documents under `docs/product/`
|
|
||||||
6. nearby existing specs with related terminology or scope
|
|
||||||
|
|
||||||
Check application code only as needed to avoid wrong naming, wrong architecture, or duplicate concepts. Do not edit application code.
|
|
||||||
|
|
||||||
## Spec Directory Rules
|
|
||||||
|
|
||||||
Create a new spec directory using the next valid spec number and a kebab-case slug:
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/<number>-<slug>/
|
|
||||||
```
|
|
||||||
|
|
||||||
The exact number must be derived from the current repository state and existing numbering conventions.
|
|
||||||
|
|
||||||
Create or update only these preparation artifacts inside the selected spec directory:
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/<number>-<slug>/spec.md
|
|
||||||
specs/<number>-<slug>/plan.md
|
|
||||||
specs/<number>-<slug>/tasks.md
|
|
||||||
```
|
|
||||||
|
|
||||||
If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions. Do not create implementation files.
|
|
||||||
|
|
||||||
## `spec.md` Requirements
|
|
||||||
|
|
||||||
The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness.
|
|
||||||
|
|
||||||
Include:
|
|
||||||
|
|
||||||
- Feature title
|
|
||||||
- Problem statement
|
|
||||||
- Business/product value
|
|
||||||
- Primary users/operators
|
|
||||||
- User stories
|
|
||||||
- Functional requirements
|
|
||||||
- Non-functional requirements
|
|
||||||
- UX requirements
|
|
||||||
- RBAC/security requirements
|
|
||||||
- Auditability/observability requirements
|
|
||||||
- Data/truth-source requirements where relevant
|
|
||||||
- Out of scope
|
|
||||||
- Acceptance criteria
|
|
||||||
- Success criteria
|
|
||||||
- Risks
|
|
||||||
- Assumptions
|
|
||||||
- Open questions
|
|
||||||
|
|
||||||
TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles:
|
|
||||||
|
|
||||||
- workspace/tenant isolation
|
|
||||||
- capability-first RBAC
|
|
||||||
- auditability
|
|
||||||
- operation/result truth separation
|
|
||||||
- source-of-truth clarity
|
|
||||||
- calm enterprise operator UX
|
|
||||||
- progressive disclosure where useful
|
|
||||||
- no false positive calmness
|
|
||||||
|
|
||||||
## `plan.md` Requirements
|
|
||||||
|
|
||||||
The plan must be repo-aware and implementation-oriented, but still must not implement.
|
|
||||||
|
|
||||||
Include:
|
|
||||||
|
|
||||||
- Technical approach
|
|
||||||
- Existing repository surfaces likely affected
|
|
||||||
- Domain/model implications
|
|
||||||
- UI/Filament implications
|
|
||||||
- Livewire implications where relevant
|
|
||||||
- OperationRun/monitoring implications where relevant
|
|
||||||
- RBAC/policy implications
|
|
||||||
- Audit/logging/evidence implications where relevant
|
|
||||||
- Data/migration implications where relevant
|
|
||||||
- Test strategy
|
|
||||||
- Rollout considerations
|
|
||||||
- Risk controls
|
|
||||||
- Implementation phases
|
|
||||||
|
|
||||||
The plan should clearly distinguish:
|
|
||||||
|
|
||||||
- execution truth
|
|
||||||
- artifact truth
|
|
||||||
- backup/snapshot truth
|
|
||||||
- recovery/evidence truth
|
|
||||||
- operator next action
|
|
||||||
|
|
||||||
Use those distinctions only where relevant to the feature.
|
|
||||||
|
|
||||||
## `tasks.md` Requirements
|
|
||||||
|
|
||||||
Tasks must be ordered, small, and verifiable.
|
|
||||||
|
|
||||||
Include:
|
|
||||||
|
|
||||||
- checkbox tasks
|
|
||||||
- phase grouping
|
|
||||||
- tests before or alongside implementation tasks where practical
|
|
||||||
- final validation tasks
|
|
||||||
- documentation/update tasks if needed
|
|
||||||
- explicit non-goals where useful
|
|
||||||
|
|
||||||
Avoid vague tasks such as:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Clean up code
|
|
||||||
Refactor UI
|
|
||||||
Improve performance
|
|
||||||
Make it enterprise-ready
|
|
||||||
```
|
|
||||||
|
|
||||||
Prefer concrete tasks such as:
|
|
||||||
|
|
||||||
```text
|
|
||||||
- [ ] Add a feature test covering workspace isolation for <specific behavior>.
|
|
||||||
- [ ] Update <specific Filament page/resource> to display <specific state>.
|
|
||||||
- [ ] Add policy coverage for <specific capability>.
|
|
||||||
```
|
|
||||||
|
|
||||||
If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths.
|
|
||||||
|
|
||||||
## Scope Control
|
|
||||||
|
|
||||||
If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section.
|
|
||||||
|
|
||||||
Examples of follow-up candidates:
|
|
||||||
|
|
||||||
- assigned findings
|
|
||||||
- pending approvals
|
|
||||||
- personal work queue
|
|
||||||
- notification delivery settings
|
|
||||||
- evidence pack export hardening
|
|
||||||
- operation monitoring refinements
|
|
||||||
- autonomous governance decision surfaces
|
|
||||||
|
|
||||||
Do not force all follow-up candidates into the primary spec.
|
|
||||||
|
|
||||||
## Final Response Requirements
|
|
||||||
|
|
||||||
After creating or updating the artifacts, respond with:
|
|
||||||
|
|
||||||
1. Created or updated spec directory
|
|
||||||
2. Files created or updated
|
|
||||||
3. Important repo-based adjustments made
|
|
||||||
4. Assumptions made
|
|
||||||
5. Open questions, if any
|
|
||||||
6. Recommended next manual analysis prompt
|
|
||||||
7. Explicit statement that no implementation was performed
|
|
||||||
|
|
||||||
Keep the final response concise, but include enough detail for the user to continue immediately.
|
|
||||||
|
|
||||||
## Required Next Manual Analysis Prompt
|
|
||||||
|
|
||||||
Always provide a ready-to-copy prompt like this, adapted to the created spec number and slug:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
|
|
||||||
|
|
||||||
Analysiere die neu erstellte Spec `<spec-number>-<slug>` streng repo-basiert.
|
|
||||||
|
|
||||||
Ziel:
|
|
||||||
Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind.
|
|
||||||
|
|
||||||
Wichtig:
|
|
||||||
- Keine Implementierung.
|
|
||||||
- Keine Codeänderungen.
|
|
||||||
- Keine Scope-Erweiterung.
|
|
||||||
- Prüfe nur gegen Repo-Wahrheit.
|
|
||||||
- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs.
|
|
||||||
- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor.
|
|
||||||
- Wenn alles passt, gib eine klare Implementierungsfreigabe.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Example Invocation
|
|
||||||
|
|
||||||
User:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Nimm diesen Spec Candidate und mach daraus spec, plan und tasks in einem Rutsch. Danach mache ich die Analyse manuell.
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected behavior:
|
|
||||||
|
|
||||||
1. Inspect constitution, templates, specs, roadmap, and candidate docs.
|
|
||||||
2. Determine the next valid spec number.
|
|
||||||
3. Create `spec.md`, `plan.md`, and `tasks.md` in the new spec directory.
|
|
||||||
4. Keep scope tight.
|
|
||||||
5. Do not implement.
|
|
||||||
6. Return the summary and next manual analysis prompt.
|
|
||||||
@ -1,30 +1,34 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 2.9.0 -> 2.10.0
|
- Version change: 2.10.0 -> 2.11.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Expanded Operations / Run Observability Standard so OperationRun
|
- Expanded decision-first and operator-surface rules so operational,
|
||||||
start UX is shared-contract-owned instead of surface-owned
|
governance, evidence, onboarding, review, and support-facing
|
||||||
- Expanded Governance review expectations for OperationRun-starting
|
detail/status surfaces separate decision content, operator
|
||||||
features, explicit queued-notification policy, and bounded
|
diagnostics, and support/raw evidence
|
||||||
exceptions
|
- Expanded review and enforcement expectations so specs, plans,
|
||||||
|
tasks, and checklists must make audience modes, raw/support
|
||||||
|
gating, one dominant next action, and duplicate-truth prevention
|
||||||
|
explicit
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- OperationRun Start UX Contract (OPS-UX-START-001): centralizes
|
- Audience-Aware Decision Surfaces & Disclosure Ladder
|
||||||
queued toast/link/event/message semantics, run/artifact deep links,
|
(DECIDE-AUD-001): requires customer-readable default paths,
|
||||||
queued DB-notification policy, and tenant/workspace-safe operation
|
operator diagnostics as progressive disclosure, support/raw
|
||||||
URL resolution behind one shared OperationRun UX layer
|
evidence gating, one dominant next action, and no duplicate truth
|
||||||
|
across equal-priority cards
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- .specify/templates/spec-template.md: add OperationRun UX Impact
|
- .specify/templates/spec-template.md: add audience-aware disclosure
|
||||||
section + start-contract prompts ✅
|
section + constitution prompts ✅
|
||||||
- .specify/templates/plan-template.md: add OperationRun UX Impact
|
- .specify/templates/plan-template.md: add audience/disclosure
|
||||||
planning section + constitution checks ✅
|
planning prompts + constitution checks ✅
|
||||||
- .specify/templates/tasks-template.md: add central start-UX reuse,
|
- .specify/templates/tasks-template.md: add decision/disclosure
|
||||||
queued-notification policy, and exception tasks ✅
|
implementation + test tasks ✅
|
||||||
- .specify/templates/checklist-template.md: add OperationRun start
|
- .specify/templates/checklist-template.md: add disclosure, raw-gating,
|
||||||
UX review checks ✅
|
one-primary-action, and duplicate-truth review checks ✅
|
||||||
- docs/product/standards/README.md: refresh constitution index for
|
- docs/product/standards/README.md: refresh constitution index for
|
||||||
the new ops-UX contract ✅
|
the new audience-aware disclosure contract ✅
|
||||||
- Commands checked:
|
- Commands checked:
|
||||||
- N/A `.specify/templates/commands/*.md` directory is not present
|
- N/A `.specify/templates/commands/*.md` directory is not present
|
||||||
- Follow-up TODOs: None
|
- Follow-up TODOs: None
|
||||||
@ -589,11 +593,114 @@ ##### 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
|
||||||
@ -1317,11 +1424,22 @@ #### Operator Surface Principles (OPSURF-001)
|
|||||||
- Diagnostic detail MAY exist, but it MUST be secondary and explicitly revealed.
|
- Diagnostic detail MAY exist, but it MUST be secondary and explicitly revealed.
|
||||||
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces rather than the primary content region.
|
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces rather than the primary content region.
|
||||||
- Operators MUST NOT need to parse raw payloads to understand current state or next action.
|
- Operators MUST NOT need to parse raw payloads to understand current state or next action.
|
||||||
|
- Detail/status surfaces MUST satisfy DECIDE-AUD-001: decision content
|
||||||
|
first, operator diagnostics second, support/raw evidence third.
|
||||||
|
|
||||||
Distinct truth dimensions
|
Distinct truth dimensions
|
||||||
- When the domain has execution outcome, data completeness, governance result, lifecycle or readiness state, operability truth, health truth, trust/confidence, or next action semantics, the surface MUST keep them explicit instead of collapsing them into one ambiguous status.
|
- When the domain has execution outcome, data completeness, governance result, lifecycle or readiness state, operability truth, health truth, trust/confidence, or next action semantics, the surface MUST keep them explicit instead of collapsing them into one ambiguous status.
|
||||||
- If multiple truth dimensions are summarized, the default-visible UI MUST label each dimension clearly.
|
- If multiple truth dimensions are summarized, the default-visible UI MUST label each dimension clearly.
|
||||||
|
|
||||||
|
Dominant next action and duplicate-truth control
|
||||||
|
- Default-visible decision content MUST include status, reason,
|
||||||
|
impact, and one dominant next action where those concepts exist.
|
||||||
|
- Secondary navigation or debug helpers MUST remain lower-priority
|
||||||
|
than the dominant decision action.
|
||||||
|
- The same blocker, reason, impact, or next action MUST NOT be
|
||||||
|
repeated across multiple default-visible cards, sections, tabs, or
|
||||||
|
summaries.
|
||||||
|
|
||||||
Explicit mutation scope
|
Explicit mutation scope
|
||||||
- Every state-changing action MUST communicate before execution whether it affects TenantPilot only, the Microsoft tenant, or simulation only.
|
- Every state-changing action MUST communicate before execution whether it affects TenantPilot only, the Microsoft tenant, or simulation only.
|
||||||
- Mutation scope MUST be understandable from nearby action copy, helper text, preview, or confirmation.
|
- Mutation scope MUST be understandable from nearby action copy, helper text, preview, or confirmation.
|
||||||
@ -1342,6 +1460,13 @@ #### Operator Surface Principles (OPSURF-001)
|
|||||||
Page contract requirement
|
Page contract requirement
|
||||||
- Every new or materially refactored operator-facing page MUST define the primary persona, surface type, primary operator question, default-visible information, diagnostics-only information, status dimensions used, mutation scope, primary actions, and dangerous actions.
|
- Every new or materially refactored operator-facing page MUST define the primary persona, surface type, primary operator question, default-visible information, diagnostics-only information, status dimensions used, mutation scope, primary actions, and dangerous actions.
|
||||||
- The page contract MUST live in the governing spec and stay in sync with implementation.
|
- The page contract MUST live in the governing spec and stay in sync with implementation.
|
||||||
|
- Where multiple audience classes share the page, the contract MUST
|
||||||
|
explicitly define the customer/read-only default path, operator
|
||||||
|
diagnostics path, support/raw-evidence path, and the capabilities
|
||||||
|
that unlock each layer.
|
||||||
|
- The page contract MUST also make the dominant next action,
|
||||||
|
duplicate-truth prevention, and raw/support gating explicit for
|
||||||
|
changed detail/status surfaces.
|
||||||
|
|
||||||
#### Spec Scope Fields (SCOPE-002)
|
#### Spec Scope Fields (SCOPE-002)
|
||||||
|
|
||||||
@ -1366,8 +1491,11 @@ #### Enforcement Model (UI-REVIEW-001)
|
|||||||
native, custom, or a shared detail family, what shared core vs host
|
native, custom, or a shared detail family, what shared core vs host
|
||||||
variation exists if relevant, which layer owns the relevant shell,
|
variation exists if relevant, which layer owns the relevant shell,
|
||||||
page, and detail truth, which requested/active/draft/inspect/
|
page, and detail truth, which requested/active/draft/inspect/
|
||||||
restorable roles exist, whether any fake-native or host-drift risk is
|
restorable roles exist, which audience ladder and disclosure
|
||||||
present, and whether an exception type is used.
|
boundaries exist, what the dominant next action is, how raw/support
|
||||||
|
evidence is gated, how duplicate truth is prevented, whether any
|
||||||
|
fake-native or host-drift risk is present, and whether an exception
|
||||||
|
type is used.
|
||||||
- Missing any of those answers makes the spec incomplete.
|
- Missing any of those answers makes the spec incomplete.
|
||||||
|
|
||||||
PR review requirements
|
PR review requirements
|
||||||
@ -1382,7 +1510,12 @@ #### Enforcement Model (UI-REVIEW-001)
|
|||||||
promoted into primary navigation without justification, one case
|
promoted into primary navigation without justification, one case
|
||||||
fragmented across multiple equal-rank pages, new automation that adds
|
fragmented across multiple equal-rank pages, new automation that adds
|
||||||
attention surfaces without reducing operator work, noisy default
|
attention surfaces without reducing operator work, noisy default
|
||||||
surfaces with no action/watch/reference hierarchy, `Filament Costume`,
|
surfaces with no action/watch/reference hierarchy, duplicate visible
|
||||||
|
blocker/reason/next-action summaries, customer/operator default paths
|
||||||
|
that expose raw JSON, fingerprints, reason ownership, platform reason
|
||||||
|
families, or monitoring detail, helper actions such as `Open
|
||||||
|
operation`, `Technical details`, or `Show JSON` competing with the
|
||||||
|
dominant decision action, `Filament Costume`,
|
||||||
`Blade Request UI`, `Hand-Rolled Simple Overview`, `Hidden Exception`,
|
`Blade Request UI`, `Hand-Rolled Simple Overview`, `Hidden Exception`,
|
||||||
`Host Drift`, `State Layer Collapse`, `Parallel Inspect Worlds`, or
|
`Host Drift`, `State Layer Collapse`, `Parallel Inspect Worlds`, or
|
||||||
undocumented exceptions without dedicated tests.
|
undocumented exceptions without dedicated tests.
|
||||||
@ -1394,11 +1527,15 @@ #### Enforcement Model (UI-REVIEW-001)
|
|||||||
presence of explicit Inspect on Queue / Review and History / Audit
|
presence of explicit Inspect on Queue / Review and History / Audit
|
||||||
surfaces, absence of empty `ActionGroup` or `BulkActionGroup`,
|
surfaces, absence of empty `ActionGroup` or `BulkActionGroup`,
|
||||||
correct placement of destructive actions, truthful scope signals,
|
correct placement of destructive actions, truthful scope signals,
|
||||||
stable canonical nouns across shells, absence of fake-native primary
|
stable canonical nouns across shells, presence of a single dominant
|
||||||
controls where metadata says the surface is native, bounded shared
|
next action where surface metadata exposes one, absence of duplicate
|
||||||
family contracts where metadata says a family is reused, explicit
|
visible decision summaries, explicit raw/support gating or secondary
|
||||||
state ownership where specs or metadata expose it, and dedicated
|
placement where the surface serves multiple audience classes,
|
||||||
tests for every approved exception.
|
absence of fake-native primary controls where metadata says the
|
||||||
|
surface is native, bounded shared family contracts where metadata
|
||||||
|
says a family is reused, explicit state ownership where specs or
|
||||||
|
metadata expose it, and dedicated tests for every approved
|
||||||
|
exception.
|
||||||
|
|
||||||
#### Immediate Retrofit Priorities
|
#### Immediate Retrofit Priorities
|
||||||
|
|
||||||
@ -1465,6 +1602,10 @@ #### Appendix A - One-page Condensed Constitution
|
|||||||
- Scope chips must be truthful.
|
- Scope chips must be truthful.
|
||||||
- Domain nouns are canonical and stable.
|
- Domain nouns are canonical and stable.
|
||||||
- Critical operational truth is default-visible.
|
- Critical operational truth is default-visible.
|
||||||
|
- Multi-audience detail/status surfaces keep customer-readable decision
|
||||||
|
content above operator diagnostics and support/raw evidence.
|
||||||
|
- One dominant next action stays visually primary.
|
||||||
|
- Duplicate visible decision truth is forbidden.
|
||||||
- Semantic truth dimensions are not collapsed into a generic status.
|
- Semantic truth dimensions are not collapsed into a generic status.
|
||||||
- Standard lists stay scanable.
|
- Standard lists stay scanable.
|
||||||
- Exceptions are catalogued, justified, and tested.
|
- Exceptions are catalogued, justified, and tested.
|
||||||
@ -1477,6 +1618,8 @@ #### Appendix B - Feature Review Checklist
|
|||||||
- The human-in-the-loop moment is explicit.
|
- The human-in-the-loop moment is explicit.
|
||||||
- Immediate-visible decision information is explicit.
|
- Immediate-visible decision information is explicit.
|
||||||
- On-demand evidence / diagnostics boundaries are explicit.
|
- On-demand evidence / diagnostics boundaries are explicit.
|
||||||
|
- Audience-aware default visibility and raw-evidence boundaries are
|
||||||
|
explicit where the page serves more than one audience class.
|
||||||
- Any new primary surface is justified against an existing decision
|
- Any new primary surface is justified against an existing decision
|
||||||
context.
|
context.
|
||||||
- Navigation reflects a workflow rather than storage structure.
|
- Navigation reflects a workflow rather than storage structure.
|
||||||
@ -1486,6 +1629,8 @@ #### Appendix B - Feature Review Checklist
|
|||||||
- Broad action-surface class is declared.
|
- Broad action-surface class is declared.
|
||||||
- Detailed surface type is declared.
|
- Detailed surface type is declared.
|
||||||
- The one most likely next operator action is explicit.
|
- The one most likely next operator action is explicit.
|
||||||
|
- One dominant next action stays primary.
|
||||||
|
- Duplicate visible decision truth is absent.
|
||||||
- The surface is classified correctly as native, custom, or shared
|
- The surface is classified correctly as native, custom, or shared
|
||||||
family.
|
family.
|
||||||
- Primary inspect/open model is defined.
|
- Primary inspect/open model is defined.
|
||||||
@ -1567,6 +1712,10 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
|||||||
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
|
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
|
||||||
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
||||||
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
|
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
|
||||||
|
- Local Blade/Tailwind cards are allowed only when they preserve dark
|
||||||
|
mode correctness, spacing consistency, badge semantics, action
|
||||||
|
hierarchy, progressive disclosure, accessibility, and overall
|
||||||
|
Filament visual language.
|
||||||
|
|
||||||
Native-by-default classification
|
Native-by-default classification
|
||||||
- `Native Surface` means the primary interaction contract is built from
|
- `Native Surface` means the primary interaction contract is built from
|
||||||
@ -1598,6 +1747,8 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
|||||||
more than one host, it becomes a `Shared Detail Micro-UI` and MUST
|
more than one host, it becomes a `Shared Detail Micro-UI` and MUST
|
||||||
define shared core vs host variation before another host reassembles
|
define shared core vs host variation before another host reassembles
|
||||||
it locally.
|
it locally.
|
||||||
|
- Local one-off markup MUST NOT recreate decision/diagnostics/raw
|
||||||
|
layering when an existing shared detail family is sufficient.
|
||||||
|
|
||||||
Upgrade-safe preference
|
Upgrade-safe preference
|
||||||
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
||||||
@ -1611,7 +1762,9 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
|||||||
- and the deviation is justified briefly in code and in the governing spec or PR.
|
- and the deviation is justified briefly in code and in the governing spec or PR.
|
||||||
- Approved exceptions MUST stay layout-neutral, use the minimum local
|
- Approved exceptions MUST stay layout-neutral, use the minimum local
|
||||||
classes necessary, MUST NOT invent a new page-local status language,
|
classes necessary, MUST NOT invent a new page-local status language,
|
||||||
and MUST say what remains standardized.
|
MUST preserve dark mode correctness, spacing consistency,
|
||||||
|
badge semantics, action hierarchy, progressive disclosure,
|
||||||
|
accessibility, and MUST say what remains standardized.
|
||||||
- `Hidden Exception` is forbidden. Historical accident or local
|
- `Hidden Exception` is forbidden. Historical accident or local
|
||||||
implementation convenience is not a valid substitute for UI-EX-001.
|
implementation convenience is not a valid substitute for UI-EX-001.
|
||||||
|
|
||||||
@ -1620,6 +1773,8 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
|||||||
- which native Filament element or shared primitive was used,
|
- which native Filament element or shared primitive was used,
|
||||||
- why an existing component was insufficient if an exception was taken,
|
- why an existing component was insufficient if an exception was taken,
|
||||||
- whether the surface is native, custom, or a shared detail family,
|
- whether the surface is native, custom, or a shared detail family,
|
||||||
|
- whether any local Blade/Tailwind card still preserves Filament
|
||||||
|
visual language and disclosure semantics,
|
||||||
- and whether any ad-hoc status, emphasis styling, or fake-native
|
- and whether any ad-hoc status, emphasis styling, or fake-native
|
||||||
contract was introduced.
|
contract was introduced.
|
||||||
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
||||||
@ -1658,6 +1813,11 @@ ### Scope, Compliance, and Review Expectations
|
|||||||
different policy, route terminal notifications through the lifecycle mechanism, include an `OperationRun UX Impact`
|
different policy, route terminal notifications through the lifecycle mechanism, include an `OperationRun UX Impact`
|
||||||
section in the active spec or plan, and document any temporary exception with an architecture note, test rationale,
|
section in the active spec or plan, and document any temporary exception with an architecture note, test rationale,
|
||||||
and migration decision.
|
and migration decision.
|
||||||
|
- Specs and PRs that change detail or status surfaces MUST explicitly
|
||||||
|
document how they satisfy customer-readable decision-first content,
|
||||||
|
diagnostics-secondary disclosure, support/raw-evidence gating, one
|
||||||
|
dominant next action, duplicate-truth prevention, and shared-pattern
|
||||||
|
reuse.
|
||||||
- Specs and PRs that change operator-facing surfaces MUST classify each
|
- Specs and PRs that change operator-facing surfaces MUST classify each
|
||||||
affected surface under DECIDE-001 and justify any new Primary
|
affected surface under DECIDE-001 and justify any new Primary
|
||||||
Decision Surface or workflow-first navigation change.
|
Decision Surface or workflow-first navigation change.
|
||||||
@ -1675,4 +1835,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 2.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-24
|
**Version**: 2.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-27
|
||||||
|
|||||||
@ -51,6 +51,14 @@ ## Signals, Exceptions, And Test Depth
|
|||||||
- [ ] CHK014 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
- [ ] CHK014 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
||||||
- [ ] CHK015 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
- [ ] CHK015 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
||||||
|
|
||||||
|
## Audience-Aware Disclosure And Decision Hierarchy
|
||||||
|
|
||||||
|
- [ ] CHK023 Default-visible content is decision-first and clearly separated from operator diagnostics and support/raw evidence.
|
||||||
|
- [ ] CHK024 Customer/read-only default paths do not expose raw JSON, copied context payloads, fingerprints, internal reason ownership, platform reason families, monitoring detail, or other debug semantics by default.
|
||||||
|
- [ ] CHK025 Exactly one dominant next action is primary; navigation or debug helpers such as `Open operation`, `Technical details`, or `Show JSON` do not compete at equal weight.
|
||||||
|
- [ ] CHK026 Duplicate visible status, blocker, reason, impact, or next-action summaries are removed or explicitly justified as non-duplicative evidence.
|
||||||
|
- [ ] CHK027 Support/raw sections are collapsed, lower-priority, or capability-gated where applicable, and any local Blade/Tailwind surface still preserves Filament visual language, dark mode correctness, progressive disclosure, and accessibility.
|
||||||
|
|
||||||
## Review Outcome
|
## Review Outcome
|
||||||
|
|
||||||
- [ ] CHK016 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
- [ ] CHK016 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
||||||
|
|||||||
@ -36,6 +36,10 @@ ## UI / Surface Guardrail Plan
|
|||||||
- **Native vs custom classification summary**: [native / custom / mixed / N/A]
|
- **Native vs custom classification summary**: [native / custom / mixed / N/A]
|
||||||
- **Shared-family relevance**: [none / list affected shared families]
|
- **Shared-family relevance**: [none / list affected shared families]
|
||||||
- **State layers in scope**: [shell / page / detail / URL-query / none]
|
- **State layers in scope**: [shell / page / detail / URL-query / none]
|
||||||
|
- **Audience modes in scope**: [customer/read-only / operator-MSP / support-platform / N/A]
|
||||||
|
- **Decision/diagnostic/raw hierarchy plan**: [decision-first / diagnostics-second / support-raw-third / N/A]
|
||||||
|
- **Raw/support gating plan**: [collapsed / capability-gated / role-gated / N/A]
|
||||||
|
- **One-primary-action / duplicate-truth control**: [how one dominant next action is preserved and repeated blockers are removed]
|
||||||
- **Handling modes by drift class or surface**: [hard-stop-candidate / review-mandatory / exception-required / report-only / N/A]
|
- **Handling modes by drift class or surface**: [hard-stop-candidate / review-mandatory / exception-required / report-only / N/A]
|
||||||
- **Repository-signal treatment**: [report-only / review-mandatory / exception-required / future hard-stop candidate / N/A]
|
- **Repository-signal treatment**: [report-only / review-mandatory / exception-required / future hard-stop candidate / N/A]
|
||||||
- **Special surface test profiles**: [standard-native-filament / shared-detail-family / monitoring-state-page / global-context-shell / exception-coded-surface / N/A]
|
- **Special surface test profiles**: [standard-native-filament / shared-detail-family / monitoring-state-page / global-context-shell / exception-coded-surface / N/A]
|
||||||
@ -111,6 +115,10 @@ ## Constitution Check
|
|||||||
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
||||||
|
- Filament-native UI (UI-FIL-001): if local Blade/Tailwind cards are
|
||||||
|
still necessary, they preserve dark mode correctness, spacing
|
||||||
|
consistency, badge semantics, action hierarchy, progressive
|
||||||
|
disclosure, accessibility, and Filament visual language
|
||||||
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
|
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
|
||||||
- Decision-first operating model (DECIDE-001): each changed
|
- Decision-first operating model (DECIDE-001): each changed
|
||||||
operator-facing surface is classified as Primary Decision,
|
operator-facing surface is classified as Primary Decision,
|
||||||
@ -120,6 +128,13 @@ ## Constitution Check
|
|||||||
disclosed, one governance case stays decidable in one context where
|
disclosed, one governance case stays decidable in one context where
|
||||||
practical, navigation follows workflows not storage structures, and
|
practical, navigation follows workflows not storage structures, and
|
||||||
automation / alerts reduce attention load instead of adding noise
|
automation / alerts reduce attention load instead of adding noise
|
||||||
|
- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): detail or
|
||||||
|
status surfaces separate customer-readable decision content,
|
||||||
|
operator diagnostics, and support/raw evidence; customer-readable
|
||||||
|
default paths hide raw JSON, copied context, fingerprints, internal
|
||||||
|
reason ownership, platform reason families, and debug semantics;
|
||||||
|
one dominant next action is explicit; duplicate visible truth is
|
||||||
|
removed
|
||||||
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
|
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
|
||||||
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): standard CRUD and Registry rows expose at most one inline safe shortcut; destructive actions are grouped or in the detail header; queue exceptions are catalogued, justified, and tested
|
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): standard CRUD and Registry rows expose at most one inline safe shortcut; destructive actions are grouped or in the detail header; queue exceptions are catalogued, justified, and tested
|
||||||
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): scope signals are truthful, canonical nouns stay stable across shells, critical operational truth is default-visible, and standard lists remain scanable
|
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): scope signals are truthful, canonical nouns stay stable across shells, critical operational truth is default-visible, and standard lists remain scanable
|
||||||
|
|||||||
@ -89,6 +89,17 @@ ## Decision-First Surface Role *(mandatory when operator-facing surfaces are cha
|
|||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
| e.g. Review inbox | Primary Decision Surface | Review and release queued governance work | Case summary, severity, recommendation, required action | Full evidence, raw payloads, audit trail, provider diagnostics | Primary because it is the queue where operators decide and clear work | Follows pending-decisions workflow, not storage objects | Removes search across runs, findings, and audit pages |
|
| e.g. Review inbox | Primary Decision Surface | Review and release queued governance work | Case summary, severity, recommendation, required action | Full evidence, raw payloads, audit trail, provider diagnostics | Primary because it is the queue where operators decide and clear work | Follows pending-decisions workflow, not storage objects | Removes search across runs, findings, and audit pages |
|
||||||
|
|
||||||
|
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
If this feature adds or materially changes a detail or status surface,
|
||||||
|
fill out one row per affected surface. Reuse the same surface names
|
||||||
|
used above and make the disclosure hierarchy explicit instead of
|
||||||
|
assuming it.
|
||||||
|
|
||||||
|
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| e.g. Review inbox | customer-read-only, operator-MSP, support-platform | Current status, why it matters, impact, recommendation, next action | Review history, lifecycle, related evidence, related runs | Raw payloads, fingerprints, reason ownership, platform reason family | `Review evidence` | Raw/support detail hidden or capability-gated outside support mode | The top summary states the blocker once; later sections add evidence rather than restating it |
|
||||||
|
|
||||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
||||||
@ -254,6 +265,13 @@ ## Requirements *(mandatory)*
|
|||||||
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
|
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
|
||||||
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
|
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
|
||||||
|
|
||||||
|
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** If this feature changes a detail or status surface, the spec MUST describe:
|
||||||
|
- how the surface separates customer-readable decision content, operator diagnostics, and support/raw evidence,
|
||||||
|
- which audience modes are in scope (`customer/read-only`, `operator/MSP`, `support/platform`),
|
||||||
|
- which content is hidden, collapsed, or capability-gated by default,
|
||||||
|
- how one dominant next action is preserved,
|
||||||
|
- and how duplicate visible truth is prevented.
|
||||||
|
|
||||||
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
|
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
|
||||||
- classify each touched seam as provider-owned or platform-core,
|
- classify each touched seam as provider-owned or platform-core,
|
||||||
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
|
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
|
||||||
@ -310,6 +328,7 @@ ## Requirements *(mandatory)*
|
|||||||
- which native Filament components or shared UI primitives are used,
|
- which native Filament components or shared UI primitives are used,
|
||||||
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
||||||
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
||||||
|
- how any required local Blade/Tailwind cards still preserve dark mode correctness, spacing consistency, badge semantics, action hierarchy, progressive disclosure, accessibility, and Filament visual language,
|
||||||
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||||
@ -367,6 +386,7 @@ ## Requirements *(mandatory)*
|
|||||||
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||||
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
||||||
- which diagnostics are secondary and how they are explicitly revealed,
|
- which diagnostics are secondary and how they are explicitly revealed,
|
||||||
|
- how the dominant next action stays primary and how duplicate visible truth is avoided,
|
||||||
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
||||||
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
||||||
|
|||||||
@ -78,9 +78,21 @@ # Tasks: [FEATURE NAME]
|
|||||||
- filling the spec’s Operator Surface Contract for every affected page,
|
- filling the spec’s Operator Surface Contract for every affected page,
|
||||||
- keeping default-visible content limited to first-decision needs and
|
- keeping default-visible content limited to first-decision needs and
|
||||||
moving proof, payloads, and diagnostics into progressive disclosure,
|
moving proof, payloads, and diagnostics into progressive disclosure,
|
||||||
|
- implementing the three-tier disclosure hierarchy where applicable:
|
||||||
|
customer-readable decision content first, operator diagnostics
|
||||||
|
second, support/raw evidence third,
|
||||||
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
||||||
|
- ensuring customer/read-only default paths do not expose raw JSON,
|
||||||
|
copied context payloads, fingerprints, internal reason ownership,
|
||||||
|
platform reason families, or debug semantics,
|
||||||
- keeping each governance case decidable in one focused context where
|
- keeping each governance case decidable in one focused context where
|
||||||
practical instead of forcing cross-page reconstruction,
|
practical instead of forcing cross-page reconstruction,
|
||||||
|
- keeping exactly one dominant next action primary and demoting
|
||||||
|
navigation/debug helpers such as `Open operation`, `Technical
|
||||||
|
details`, or `Show JSON`,
|
||||||
|
- removing duplicate visible status, blocker, reason, impact, or
|
||||||
|
next-action summaries so later sections add evidence instead of
|
||||||
|
restating the same decision truth,
|
||||||
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
||||||
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||||
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
||||||
@ -128,6 +140,12 @@ # Tasks: [FEATURE NAME]
|
|||||||
- documenting any catalogued UI exception in the spec/PR and adding dedicated test coverage,
|
- documenting any catalogued UI exception in the spec/PR and adding dedicated test coverage,
|
||||||
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
||||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||||
|
- For any new or modified customer/operator-facing detail surface,
|
||||||
|
tests MUST prove default-visible status/reason/impact/next-action
|
||||||
|
content, exactly one dominant next action, diagnostics-secondary
|
||||||
|
ordering, hidden raw/support detail by default, capability-gated
|
||||||
|
support/raw sections where applicable, and the absence of duplicate
|
||||||
|
visible decision summaries.
|
||||||
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
||||||
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
||||||
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
|
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
|
||||||
|
|||||||
@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\ProductUsageEvent;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class PruneProductUsageEventsCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'tenantpilot:product-usage:prune {--days= : Number of days to retain product usage events}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Delete product usage events older than the retention period';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$days = (int) ($this->option('days') ?: config('tenantpilot.product_usage_event_retention_days', 90));
|
||||||
|
|
||||||
|
if ($days < 1) {
|
||||||
|
$this->error('Retention days must be at least 1.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = now()->subDays($days);
|
||||||
|
|
||||||
|
$deleted = ProductUsageEvent::query()
|
||||||
|
->where('occurred_at', '<', $cutoff)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->info("Deleted {$deleted} product usage event(s) older than {$days} days.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||||
|
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
@ -51,6 +52,14 @@ public function handle(FindingsLifecycleBackfillRunbookService $runbookService):
|
|||||||
reason: null,
|
reason: null,
|
||||||
source: 'cli',
|
source: 'cli',
|
||||||
);
|
);
|
||||||
|
} catch (OperationalControlBlockedException $e) {
|
||||||
|
$this->error(sprintf(
|
||||||
|
'Backfill paused for tenant %d: %s',
|
||||||
|
(int) $tenant->getKey(),
|
||||||
|
$e->getMessage(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
$errors = $e->errors();
|
$errors = $e->errors();
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||||
use App\Services\Runbooks\RunbookReason;
|
use App\Services\Runbooks\RunbookReason;
|
||||||
|
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
@ -31,6 +32,10 @@ public function handle(FindingsLifecycleBackfillRunbookService $runbookService):
|
|||||||
|
|
||||||
$this->info('Deploy runbooks started (if needed).');
|
$this->info('Deploy runbooks started (if needed).');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} catch (OperationalControlBlockedException $e) {
|
||||||
|
$this->info('Deploy runbooks paused: '.$e->getMessage());
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
$errors = $e->errors();
|
$errors = $e->errors();
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Entitlements;
|
||||||
|
|
||||||
|
final class WorkspaceEntitlementBlockedException extends \RuntimeException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $decision
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly array $decision)
|
||||||
|
{
|
||||||
|
parent::__construct((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks this action.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function decision(): array
|
||||||
|
{
|
||||||
|
return $this->decision;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,8 +6,10 @@
|
|||||||
|
|
||||||
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\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||||
@ -22,9 +24,14 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\OpsUx\RunDetailPolling;
|
use App\Support\OpsUx\RunDetailPolling;
|
||||||
|
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
|
||||||
|
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
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;
|
||||||
@ -35,10 +42,15 @@
|
|||||||
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;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Contracts\Support\Htmlable;
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@ -81,6 +93,11 @@ public function getTitle(): string|Htmlable
|
|||||||
*/
|
*/
|
||||||
public ?array $navigationContextPayload = null;
|
public ?array $navigationContextPayload = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $supportDiagnosticsAuditKeys = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<Action|ActionGroup>
|
* @return array<Action|ActionGroup>
|
||||||
*/
|
*/
|
||||||
@ -152,6 +169,14 @@ protected function getHeaderActions(): array
|
|||||||
->color('gray');
|
->color('gray');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$actions[] = ActionGroup::make([
|
||||||
|
$this->openSupportDiagnosticsAction(),
|
||||||
|
$this->requestSupportAction(),
|
||||||
|
])
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-o-ellipsis-horizontal')
|
||||||
|
->color('gray');
|
||||||
|
|
||||||
$actions[] = $this->resumeCaptureAction();
|
$actions[] = $this->resumeCaptureAction();
|
||||||
|
|
||||||
return $actions;
|
return $actions;
|
||||||
@ -208,6 +233,226 @@ public function monitoringDetailSummary(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function openSupportDiagnosticsAction(): Action
|
||||||
|
{
|
||||||
|
$action = Action::make('openSupportDiagnostics')
|
||||||
|
->label('Open support diagnostics')
|
||||||
|
->icon('heroicon-o-lifebuoy')
|
||||||
|
->color('gray')
|
||||||
|
->record($this->run)
|
||||||
|
->modal()
|
||||||
|
->slideOver()
|
||||||
|
->stickyModalHeader()
|
||||||
|
->modalHeading('Support diagnostics')
|
||||||
|
->modalDescription('Redacted operation context from existing records.')
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
|
||||||
|
->mountUsing(function (): void {
|
||||||
|
$this->auditOperationSupportDiagnosticsOpen();
|
||||||
|
})
|
||||||
|
->modalContent(fn (): View => view('filament.modals.support-diagnostic-bundle', [
|
||||||
|
'bundle' => $this->operationRunSupportDiagnosticBundle(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
return UiEnforcement::forAction($action)
|
||||||
|
->requireCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
|
||||||
|
->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>
|
||||||
|
*/
|
||||||
|
public function operationRunSupportDiagnosticBundle(): array
|
||||||
|
{
|
||||||
|
$user = $this->resolveViewerActor();
|
||||||
|
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||||
|
|
||||||
|
return app(SupportDiagnosticBundleBuilder::class)->forOperationRun($this->run, $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditOperationSupportDiagnosticsOpen(): void
|
||||||
|
{
|
||||||
|
$user = $this->resolveViewerActor();
|
||||||
|
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||||
|
|
||||||
|
$this->recordSupportDiagnosticsOpened(
|
||||||
|
tenant: $tenant,
|
||||||
|
bundle: $this->operationRunSupportDiagnosticBundle(),
|
||||||
|
user: $user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function supportDiagnosticsTenant(): ?Tenant
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->run->tenant;
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
return $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
|
||||||
|
*/
|
||||||
|
private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditKey = 'operation:'.$this->run->getKey();
|
||||||
|
|
||||||
|
if (in_array($auditKey, $this->supportDiagnosticsAuditKeys, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(WorkspaceAuditLogger::class)->logSupportDiagnosticsOpened(
|
||||||
|
tenant: $tenant,
|
||||||
|
contextType: 'operation_run',
|
||||||
|
bundle: $bundle,
|
||||||
|
actor: $user,
|
||||||
|
operationRun: $this->run,
|
||||||
|
);
|
||||||
|
|
||||||
|
app(ProductTelemetryRecorder::class)->record(
|
||||||
|
eventName: ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED,
|
||||||
|
workspaceId: (int) $tenant->workspace_id,
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
subjectType: 'operation_run',
|
||||||
|
subjectId: (int) $this->run->getKey(),
|
||||||
|
metadata: [
|
||||||
|
'source_surface' => 'operation_run_viewer',
|
||||||
|
'operation_type' => (string) $this->run->type,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->supportDiagnosticsAuditKeys[] = $auditKey;
|
||||||
|
}
|
||||||
|
|
||||||
public function mount(OperationRun $run): void
|
public function mount(OperationRun $run): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|||||||
@ -0,0 +1,497 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Reviews;
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
|
use App\Models\TenantReview;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\ReviewPackService;
|
||||||
|
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Findings\FindingOutcomeSemantics;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\ReviewPackStatus;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\SurfaceCompressionContext;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class CustomerReviewWorkspace extends Page implements HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
|
||||||
|
|
||||||
|
private const string SOURCE_SURFACE = 'customer_review_workspace';
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Customer reviews';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 44;
|
||||||
|
|
||||||
|
protected static ?string $title = 'Customer Review Workspace';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'reviews/workspace';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.reviews.customer-review-workspace';
|
||||||
|
|
||||||
|
public static function tenantPrefilterUrl(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
$tenantIdentifier = filled($tenant->external_id)
|
||||||
|
? (string) $tenant->external_id
|
||||||
|
: (string) $tenant->getKey();
|
||||||
|
|
||||||
|
return static::getUrl(panel: 'admin').'?'.http_build_query([
|
||||||
|
'tenant' => $tenantIdentifier,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, Tenant>|null
|
||||||
|
*/
|
||||||
|
private ?array $authorizedTenants = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->authorizePageAccess();
|
||||||
|
$this->applyRequestedTenantPrefilter();
|
||||||
|
$this->mountInteractsWithTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('clear_filters')
|
||||||
|
->label('Clear filters')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->hasActiveFilters())
|
||||||
|
->action(function (): void {
|
||||||
|
$this->clearWorkspaceFilters();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(fn (): Builder => $this->workspaceQuery())
|
||||||
|
->defaultSort('name')
|
||||||
|
->paginated(TablePaginationProfiles::customPage())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
|
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')->label('Tenant')->searchable()->sortable(),
|
||||||
|
TextColumn::make('latest_review')
|
||||||
|
->label('Latest review')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
|
||||||
|
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
|
||||||
|
->icon(fn (Tenant $record): ?string => $this->latestReviewStateIcon($record))
|
||||||
|
->iconColor(fn (Tenant $record): ?string => $this->latestReviewStateIconColor($record))
|
||||||
|
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('finding_summary')
|
||||||
|
->label('Key findings')
|
||||||
|
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('accepted_risk_summary')
|
||||||
|
->label('Accepted risks')
|
||||||
|
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('published_at')
|
||||||
|
->label('Published')
|
||||||
|
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('review_pack_state')
|
||||||
|
->label('Review pack')
|
||||||
|
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('tenant_id')
|
||||||
|
->label('Tenant')
|
||||||
|
->options(fn (): array => $this->tenantFilterOptions())
|
||||||
|
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$tenantId = $data['value'] ?? null;
|
||||||
|
|
||||||
|
return is_numeric($tenantId)
|
||||||
|
? $query->whereKey((int) $tenantId)
|
||||||
|
: $query;
|
||||||
|
})
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Action::make('open_latest_review')
|
||||||
|
->label('Open latest review')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
||||||
|
->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview),
|
||||||
|
Action::make('download_review_pack')
|
||||||
|
->label('Download review pack')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record))
|
||||||
|
->openUrlInNewTab()
|
||||||
|
->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))),
|
||||||
|
])
|
||||||
|
->bulkActions([])
|
||||||
|
->emptyStateHeading('No entitled tenants match this view')
|
||||||
|
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
|
||||||
|
? 'Clear the current filters to return to the full customer review workspace for your entitled tenants.'
|
||||||
|
: 'Adjust filters to return to the full customer review workspace for your entitled tenants.')
|
||||||
|
->emptyStateActions([
|
||||||
|
Action::make('clear_filters_empty')
|
||||||
|
->label('Clear filters')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->hasActiveFilters())
|
||||||
|
->action(fn (): mixed => $this->clearWorkspaceFilters()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Tenant>
|
||||||
|
*/
|
||||||
|
public function authorizedTenants(): array
|
||||||
|
{
|
||||||
|
if ($this->authorizedTenants !== null) {
|
||||||
|
return $this->authorizedTenants;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||||
|
return $this->authorizedTenants = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizePageAccess(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(TenantReviewRegisterService::class);
|
||||||
|
|
||||||
|
if (! $service->canAccessWorkspace($user, $workspace)) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->authorizedTenants() === []) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function workspaceQuery(): Builder
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||||
|
return Tenant::query()->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(TenantReviewRegisterService::class)->customerWorkspaceTenantQuery($user, $workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function tenantFilterOptions(): array
|
||||||
|
{
|
||||||
|
return collect($this->authorizedTenants())
|
||||||
|
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||||
|
(string) $tenant->getKey() => $tenant->name,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultTenantFilter(): ?string
|
||||||
|
{
|
||||||
|
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
||||||
|
|
||||||
|
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
|
||||||
|
? (string) $tenantId
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyRequestedTenantPrefilter(): void
|
||||||
|
{
|
||||||
|
$requestedTenant = request()->query('tenant', request()->query('tenant_id'));
|
||||||
|
|
||||||
|
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->authorizedTenants() as $tenant) {
|
||||||
|
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||||
|
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasActiveFilters(): bool
|
||||||
|
{
|
||||||
|
return $this->currentTenantFilterId() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearWorkspaceFilters(): void
|
||||||
|
{
|
||||||
|
app(WorkspaceContext::class)->clearLastTenantId(request());
|
||||||
|
$this->removeTableFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentTenantFilterId(): ?int
|
||||||
|
{
|
||||||
|
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||||
|
|
||||||
|
if (! is_numeric($tenantFilter)) {
|
||||||
|
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function workspace(): ?Workspace
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
return is_numeric($workspaceId)
|
||||||
|
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestPublishedReview(Tenant $tenant): ?TenantReview
|
||||||
|
{
|
||||||
|
$review = $tenant->tenantReviews->first();
|
||||||
|
|
||||||
|
return $review instanceof TenantReview ? $review : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestReviewUrl(Tenant $tenant): ?string
|
||||||
|
{
|
||||||
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
|
if (! $review instanceof TenantReview) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant').'?'.http_build_query([
|
||||||
|
self::DETAIL_CONTEXT_QUERY_KEY => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestReviewPack(Tenant $tenant): ?ReviewPack
|
||||||
|
{
|
||||||
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
$pack = $review?->currentExportReviewPack;
|
||||||
|
|
||||||
|
return $pack instanceof ReviewPack ? $pack : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestReviewPackDownloadUrl(Tenant $tenant): ?string
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$pack = $this->latestReviewPack($tenant);
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $pack instanceof ReviewPack) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
||||||
|
'source_surface' => self::SOURCE_SURFACE,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestPublishedAt(Tenant $tenant): ?\Illuminate\Support\Carbon
|
||||||
|
{
|
||||||
|
return $this->latestPublishedReview($tenant)?->published_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewTruth(Tenant $tenant): ?ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
|
return $review instanceof TenantReview
|
||||||
|
? app(ArtifactTruthPresenter::class)->forTenantReview($review)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
|
||||||
|
{
|
||||||
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
$truth = $this->reviewTruth($tenant);
|
||||||
|
|
||||||
|
if (! $review instanceof TenantReview || ! $truth instanceof ArtifactTruthEnvelope) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $presenter->compressedOutcomeFor($review, SurfaceCompressionContext::reviewRegister())
|
||||||
|
?? $presenter->compressedOutcomeFromEnvelope($truth, SurfaceCompressionContext::reviewRegister());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestReviewStateLabel(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
return $this->reviewOutcome($tenant)?->primaryLabel ?? 'No published review';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestReviewStateColor(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
return $this->reviewOutcome($tenant)?->primaryBadge->color ?? 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestReviewStateIcon(Tenant $tenant): ?string
|
||||||
|
{
|
||||||
|
return $this->reviewOutcome($tenant)?->primaryBadge->icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestReviewStateIconColor(Tenant $tenant): ?string
|
||||||
|
{
|
||||||
|
return $this->reviewOutcome($tenant)?->primaryBadge->iconColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewOutcomeDescription(Tenant $tenant): ?string
|
||||||
|
{
|
||||||
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
|
if (! $review instanceof TenantReview) {
|
||||||
|
return 'No published review available yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
$primaryReason = $this->reviewOutcome($tenant)?->primaryReason;
|
||||||
|
$summary = is_array($review->summary) ? $review->summary : [];
|
||||||
|
$findingOutcomes = $summary['finding_outcomes'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($findingOutcomes)) {
|
||||||
|
return $primaryReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
$findingOutcomeSummary = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
|
||||||
|
|
||||||
|
if ($findingOutcomeSummary === null) {
|
||||||
|
return $primaryReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findingSummary(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
|
if (! $review instanceof TenantReview) {
|
||||||
|
return 'No published review available yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = is_array($review->summary) ? $review->summary : [];
|
||||||
|
$findingCount = (int) ($summary['finding_count'] ?? 0);
|
||||||
|
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
||||||
|
$terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
|
||||||
|
|
||||||
|
if ($findingCount === 0) {
|
||||||
|
return 'No findings recorded in the published review.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($terminalOutcomes === null) {
|
||||||
|
return sprintf('%d findings summarized in the published review.', $findingCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%d findings. Terminal outcomes: %s.', $findingCount, $terminalOutcomes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function acceptedRiskSummary(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
|
if (! $review instanceof TenantReview) {
|
||||||
|
return 'No published review available yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = is_array($review->summary) ? $review->summary : [];
|
||||||
|
$riskAcceptance = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
|
||||||
|
$statusMarkedCount = (int) ($riskAcceptance['status_marked_count'] ?? 0);
|
||||||
|
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
|
||||||
|
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$statusMarkedCount === 0 => 'No accepted risks recorded.',
|
||||||
|
$warningCount > 0 => sprintf('%d accepted risks need governance follow-up (%d total).', $warningCount, $statusMarkedCount),
|
||||||
|
$validGovernedCount > 0 => sprintf('%d accepted risks are governed.', $validGovernedCount),
|
||||||
|
default => sprintf('%d accepted risks are on record.', $statusMarkedCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewPackAvailability(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
$pack = $this->latestReviewPack($tenant);
|
||||||
|
|
||||||
|
if (! $pack instanceof ReviewPack) {
|
||||||
|
return 'Unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||||
|
return 'Unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||||
|
return 'Unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Available';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Services\ReviewPackService;
|
||||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
@ -176,6 +177,10 @@ public function table(Table $table): Table
|
|||||||
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
|
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
|
||||||
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
|
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
|
||||||
&& in_array($record->status, ['ready', 'published'], true))
|
&& in_array($record->status, ['ready', 'published'], true))
|
||||||
|
->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false))
|
||||||
|
->tooltip(fn (TenantReview $record): ?string => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false)
|
||||||
|
? (string) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['block_reason'] ?? '')
|
||||||
|
: null)
|
||||||
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
|
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
|
|||||||
@ -7,7 +7,11 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceSetting;
|
use App\Models\WorkspaceSetting;
|
||||||
|
use App\Support\Ai\AiPolicyMode;
|
||||||
|
use App\Support\Ai\AiUseCaseCatalog;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
|
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
||||||
use App\Services\Settings\SettingsResolver;
|
use App\Services\Settings\SettingsResolver;
|
||||||
use App\Services\Settings\SettingsWriter;
|
use App\Services\Settings\SettingsWriter;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -20,7 +24,9 @@
|
|||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\KeyValue;
|
use Filament\Forms\Components\KeyValue;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
@ -51,6 +57,7 @@ class WorkspaceSettings extends Page
|
|||||||
* @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}>
|
* @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}>
|
||||||
*/
|
*/
|
||||||
private const SETTING_FIELDS = [
|
private const SETTING_FIELDS = [
|
||||||
|
'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'],
|
||||||
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
|
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
|
||||||
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
|
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
|
||||||
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
|
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
|
||||||
@ -58,10 +65,23 @@ class WorkspaceSettings extends Page
|
|||||||
'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'],
|
'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'],
|
||||||
'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'],
|
'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'],
|
||||||
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
|
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
|
||||||
|
'entitlements_plan_profile' => ['domain' => 'entitlements', 'key' => 'plan_profile', 'type' => 'string'],
|
||||||
|
'entitlements_managed_tenant_limit_override_value' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_value', 'type' => 'int'],
|
||||||
|
'entitlements_managed_tenant_limit_override_reason' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_reason', 'type' => 'string'],
|
||||||
|
'entitlements_review_pack_generation_override_value' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_value', 'type' => 'bool'],
|
||||||
|
'entitlements_review_pack_generation_override_reason' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_reason', 'type' => 'string'],
|
||||||
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
|
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
|
||||||
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
|
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private const ENTITLEMENT_OVERRIDE_REASON_FIELDS = [
|
||||||
|
'entitlements_managed_tenant_limit_override_value' => 'entitlements_managed_tenant_limit_override_reason',
|
||||||
|
'entitlements_review_pack_generation_override_value' => 'entitlements_review_pack_generation_override_reason',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fields rendered as Filament KeyValue components (array state, not JSON string).
|
* Fields rendered as Filament KeyValue components (array state, not JSON string).
|
||||||
*
|
*
|
||||||
@ -111,6 +131,14 @@ class WorkspaceSettings extends Page
|
|||||||
*/
|
*/
|
||||||
public array $resolvedSettings = [];
|
public array $resolvedSettings = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array{
|
||||||
|
* plan_profile?: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
|
||||||
|
* decisions?: array<string, array<string, mixed>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public array $entitlementSummary = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
|
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
|
||||||
*
|
*
|
||||||
@ -180,6 +208,71 @@ public function content(Schema $schema): Schema
|
|||||||
return $schema
|
return $schema
|
||||||
->statePath('data')
|
->statePath('data')
|
||||||
->schema([
|
->schema([
|
||||||
|
Section::make('Workspace entitlements')
|
||||||
|
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Select::make('entitlements_plan_profile')
|
||||||
|
->label('Plan profile')
|
||||||
|
->options(app(WorkspacePlanProfileCatalog::class)->optionLabels())
|
||||||
|
->placeholder(sprintf('Use default profile (%s)', app(WorkspacePlanProfileCatalog::class)->default()['label']))
|
||||||
|
->native(false)
|
||||||
|
->columnSpanFull()
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->planProfileFieldHelperText()),
|
||||||
|
TextInput::make('entitlements_managed_tenant_limit_override_value')
|
||||||
|
->label('Managed tenant activation limit override')
|
||||||
|
->placeholder('Unset (uses plan profile default)')
|
||||||
|
->suffix('tenants')
|
||||||
|
->hint('0 or greater')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(0)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->managedTenantLimitHelperText())
|
||||||
|
->hintAction($this->makeResetAction('entitlements_managed_tenant_limit_override_value')),
|
||||||
|
Textarea::make('entitlements_managed_tenant_limit_override_reason')
|
||||||
|
->label('Managed tenant activation override reason')
|
||||||
|
->rows(3)
|
||||||
|
->maxLength(500)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->managedTenantLimitReasonHelperText()),
|
||||||
|
Select::make('entitlements_review_pack_generation_override_value')
|
||||||
|
->label('Review pack generation override')
|
||||||
|
->options(self::booleanOptions())
|
||||||
|
->placeholder('Unset (uses plan profile default)')
|
||||||
|
->native(false)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->reviewPackGenerationHelperText())
|
||||||
|
->hintAction($this->makeResetAction('entitlements_review_pack_generation_override_value')),
|
||||||
|
Textarea::make('entitlements_review_pack_generation_override_reason')
|
||||||
|
->label('Review pack generation override reason')
|
||||||
|
->rows(3)
|
||||||
|
->maxLength(500)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->reviewPackGenerationReasonHelperText()),
|
||||||
|
]),
|
||||||
|
Section::make('Workspace AI policy')
|
||||||
|
->description($this->sectionDescription('ai', 'Control whether the workspace disables AI entirely or allows approved internal-only drafts on private-only infrastructure.'))
|
||||||
|
->schema([
|
||||||
|
Select::make('ai_policy_mode')
|
||||||
|
->label('AI posture')
|
||||||
|
->options(AiPolicyMode::optionLabels())
|
||||||
|
->placeholder('Unset (uses default)')
|
||||||
|
->native(false)
|
||||||
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||||
|
->helperText(fn (): string => $this->aiPolicyModeHelperText())
|
||||||
|
->hintAction($this->makeResetAction('ai_policy_mode')),
|
||||||
|
Placeholder::make('ai_approved_use_cases')
|
||||||
|
->label('Approved use cases')
|
||||||
|
->content(fn (): string => $this->aiApprovedUseCasesText()),
|
||||||
|
Placeholder::make('ai_allowed_provider_classes')
|
||||||
|
->label('Allowed provider classes')
|
||||||
|
->content(fn (): string => $this->aiAllowedProviderClassesText()),
|
||||||
|
Placeholder::make('ai_blocked_data_classifications')
|
||||||
|
->label('Blocked data classifications')
|
||||||
|
->content(fn (): string => $this->aiBlockedDataClassificationsText()),
|
||||||
|
]),
|
||||||
Section::make('Backup settings')
|
Section::make('Backup settings')
|
||||||
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
|
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
|
||||||
->schema([
|
->schema([
|
||||||
@ -455,6 +548,56 @@ public function resetSetting(string $field): void
|
|||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resetEntitlementOverridePair(string $field): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorizeWorkspaceManage($user);
|
||||||
|
|
||||||
|
if (! $this->hasEntitlementOverridePair($field)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Entitlement already uses plan profile default')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$writer = app(SettingsWriter::class);
|
||||||
|
$valueSetting = $this->settingForField($field);
|
||||||
|
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
|
||||||
|
$reasonSetting = $this->settingForField($reasonField);
|
||||||
|
|
||||||
|
if ($this->workspaceOverrideForField($field) !== null) {
|
||||||
|
$writer->resetWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $this->workspace,
|
||||||
|
domain: $valueSetting['domain'],
|
||||||
|
key: $valueSetting['key'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->workspaceOverrideForField($reasonField) !== null) {
|
||||||
|
$writer->resetWorkspaceSetting(
|
||||||
|
actor: $user,
|
||||||
|
workspace: $this->workspace,
|
||||||
|
domain: $reasonSetting['domain'],
|
||||||
|
key: $reasonSetting['key'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadFormState();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Workspace entitlement override reset')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
private function loadFormState(): void
|
private function loadFormState(): void
|
||||||
{
|
{
|
||||||
$resolver = app(SettingsResolver::class);
|
$resolver = app(SettingsResolver::class);
|
||||||
@ -490,6 +633,7 @@ private function loadFormState(): void
|
|||||||
$this->data = $data;
|
$this->data = $data;
|
||||||
$this->workspaceOverrides = $workspaceOverrides;
|
$this->workspaceOverrides = $workspaceOverrides;
|
||||||
$this->resolvedSettings = $resolvedSettings;
|
$this->resolvedSettings = $resolvedSettings;
|
||||||
|
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
|
||||||
|
|
||||||
$this->loadDomainLastModified();
|
$this->loadDomainLastModified();
|
||||||
}
|
}
|
||||||
@ -563,15 +707,25 @@ private function makeResetAction(string $field): Action
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function () use ($field): void {
|
->action(function () use ($field): void {
|
||||||
|
if ($this->isEntitlementOverrideValueField($field)) {
|
||||||
|
$this->resetEntitlementOverridePair($field);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->resetSetting($field);
|
$this->resetSetting($field);
|
||||||
})
|
})
|
||||||
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field))
|
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field))
|
||||||
->tooltip(function () use ($field): ?string {
|
->tooltip(function () use ($field): ?string {
|
||||||
if (! $this->currentUserCanManage()) {
|
if (! $this->currentUserCanManage()) {
|
||||||
return 'You do not have permission to manage workspace settings.';
|
return 'You do not have permission to manage workspace settings.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->hasWorkspaceOverride($field)) {
|
if (! $this->canResetField($field)) {
|
||||||
|
if ($this->isEntitlementOverrideValueField($field)) {
|
||||||
|
return 'No workspace override to reset.';
|
||||||
|
}
|
||||||
|
|
||||||
return 'No workspace override to reset.';
|
return 'No workspace override to reset.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -579,6 +733,200 @@ private function makeResetAction(string $field): Action
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function canResetField(string $field): bool
|
||||||
|
{
|
||||||
|
if ($this->isEntitlementOverrideValueField($field)) {
|
||||||
|
return $this->hasEntitlementOverridePair($field);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->hasWorkspaceOverride($field);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEntitlementOverrideValueField(string $field): bool
|
||||||
|
{
|
||||||
|
return array_key_exists($field, self::ENTITLEMENT_OVERRIDE_REASON_FIELDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasEntitlementOverridePair(string $field): bool
|
||||||
|
{
|
||||||
|
if (! $this->isEntitlementOverrideValueField($field)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
|
||||||
|
|
||||||
|
return $this->workspaceOverrideForField($field) !== null
|
||||||
|
|| $this->workspaceOverrideForField($reasonField) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function planProfileFieldHelperText(): string
|
||||||
|
{
|
||||||
|
$profile = $this->resolvedPlanProfile();
|
||||||
|
$selectedProfile = $this->workspaceOverrideForField('entitlements_plan_profile');
|
||||||
|
|
||||||
|
if (! is_string($selectedProfile) || $selectedProfile === '') {
|
||||||
|
return sprintf('Default profile: %s. %s', $profile['label'], $profile['description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('Effective profile: %s. %s', $profile['label'], $profile['description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function managedTenantLimitHelperText(): string
|
||||||
|
{
|
||||||
|
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
|
||||||
|
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
|
||||||
|
$currentUsage = (int) ($decision['current_usage'] ?? 0);
|
||||||
|
$remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
|
||||||
|
|
||||||
|
$capacityText = $remainingCapacity < 0
|
||||||
|
? sprintf('Over limit by %d.', abs($remainingCapacity))
|
||||||
|
: sprintf('%d remaining.', $remainingCapacity);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Effective limit: %d active managed tenants. Current usage: %d. %s Source: %s.',
|
||||||
|
$effectiveValue,
|
||||||
|
$currentUsage,
|
||||||
|
$capacityText,
|
||||||
|
$this->entitlementSourceLabel($decision),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function managedTenantLimitReasonHelperText(): string
|
||||||
|
{
|
||||||
|
return $this->entitlementReasonHelperText(
|
||||||
|
valueField: 'entitlements_managed_tenant_limit_override_value',
|
||||||
|
key: WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewPackGenerationHelperText(): string
|
||||||
|
{
|
||||||
|
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Effective state: %s. Source: %s.',
|
||||||
|
(bool) ($decision['effective_value'] ?? false) ? 'enabled' : 'disabled',
|
||||||
|
$this->entitlementSourceLabel($decision),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewPackGenerationReasonHelperText(): string
|
||||||
|
{
|
||||||
|
return $this->entitlementReasonHelperText(
|
||||||
|
valueField: 'entitlements_review_pack_generation_override_value',
|
||||||
|
key: WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function aiPolicyModeHelperText(): string
|
||||||
|
{
|
||||||
|
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($resolved)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$mode = AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
|
||||||
|
?? AiPolicyMode::Disabled;
|
||||||
|
|
||||||
|
$prefix = ! $this->hasWorkspaceOverride('ai_policy_mode')
|
||||||
|
? sprintf('Effective posture: %s. Source: %s.', $mode->label(), $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')))
|
||||||
|
: sprintf('Effective posture: %s.', $mode->label());
|
||||||
|
|
||||||
|
return sprintf('%s %s', $prefix, $mode->summary());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function aiApprovedUseCasesText(): string
|
||||||
|
{
|
||||||
|
return implode('; ', app(AiUseCaseCatalog::class)->labels()).'.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function aiAllowedProviderClassesText(): string
|
||||||
|
{
|
||||||
|
$labels = app(AiUseCaseCatalog::class)->allowedProviderClassLabelsForMode($this->effectiveAiPolicyMode());
|
||||||
|
|
||||||
|
if ($labels === []) {
|
||||||
|
return 'No provider classes are allowed while AI is disabled.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', $labels).'.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function aiBlockedDataClassificationsText(): string
|
||||||
|
{
|
||||||
|
return implode(', ', app(AiUseCaseCatalog::class)->blockedDataClassificationLabels()).'.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function effectiveAiPolicyMode(): AiPolicyMode
|
||||||
|
{
|
||||||
|
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($resolved)) {
|
||||||
|
return AiPolicyMode::Disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
|
||||||
|
?? AiPolicyMode::Disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function entitlementReasonHelperText(string $valueField, string $key): string
|
||||||
|
{
|
||||||
|
$decision = $this->entitlementDecision($key);
|
||||||
|
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
|
||||||
|
|
||||||
|
if ($this->workspaceOverrideForField($valueField) === null) {
|
||||||
|
return 'Required when an explicit override value is set.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rationale === null || $rationale === '') {
|
||||||
|
return 'Required when an explicit override value is set.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('Current rationale: %s', $rationale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
||||||
|
*/
|
||||||
|
private function resolvedPlanProfile(): array
|
||||||
|
{
|
||||||
|
$profile = $this->entitlementSummary['plan_profile'] ?? null;
|
||||||
|
|
||||||
|
if (is_array($profile)) {
|
||||||
|
return $profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(WorkspacePlanProfileCatalog::class)->default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function entitlementDecision(string $key): array
|
||||||
|
{
|
||||||
|
$decision = $this->entitlementSummary['decisions'][$key] ?? null;
|
||||||
|
|
||||||
|
return is_array($decision) ? $decision : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $decision
|
||||||
|
*/
|
||||||
|
private function entitlementSourceLabel(array $decision): string
|
||||||
|
{
|
||||||
|
if (($decision['source'] ?? null) === 'workspace_override') {
|
||||||
|
return 'workspace override';
|
||||||
|
}
|
||||||
|
|
||||||
|
$planProfileLabel = $decision['plan_profile_label'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($planProfileLabel) && $planProfileLabel !== '') {
|
||||||
|
return sprintf('%s plan profile', $planProfileLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'plan profile default';
|
||||||
|
}
|
||||||
|
|
||||||
private function helperTextFor(string $field): string
|
private function helperTextFor(string $field): string
|
||||||
{
|
{
|
||||||
$resolved = $this->resolvedSettings[$field] ?? null;
|
$resolved = $this->resolvedSettings[$field] ?? null;
|
||||||
@ -721,6 +1069,27 @@ private function normalizedInputValues(): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (self::ENTITLEMENT_OVERRIDE_REASON_FIELDS as $valueField => $reasonField) {
|
||||||
|
if (($normalizedValues[$valueField] ?? null) === null) {
|
||||||
|
$normalizedValues[$reasonField] = null;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($normalizedValues[$reasonField] ?? null) !== null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = match ($valueField) {
|
||||||
|
'entitlements_managed_tenant_limit_override_value' => 'Override reason is required when a managed tenant activation limit override is set.',
|
||||||
|
'entitlements_review_pack_generation_override_value' => 'Override reason is required when a review pack generation override is set.',
|
||||||
|
default => 'Override reason is required when an explicit override is set.',
|
||||||
|
};
|
||||||
|
|
||||||
|
$validationErrors['data.'.$reasonField] ??= [];
|
||||||
|
$validationErrors['data.'.$reasonField][] = $message;
|
||||||
|
}
|
||||||
|
|
||||||
return [$normalizedValues, $validationErrors];
|
return [$normalizedValues, $validationErrors];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,13 +11,37 @@
|
|||||||
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\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
|
||||||
|
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||||
|
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
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;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class TenantDashboard extends Dashboard
|
class TenantDashboard extends Dashboard
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $supportDiagnosticsAuditKeys = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<mixed> $parameters
|
* @param array<mixed> $parameters
|
||||||
*/
|
*/
|
||||||
@ -46,4 +70,210 @@ public function getColumns(): int|array
|
|||||||
{
|
{
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$this->requestSupportAction(),
|
||||||
|
$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
|
||||||
|
{
|
||||||
|
$action = Action::make('openSupportDiagnostics')
|
||||||
|
->label('Open support diagnostics')
|
||||||
|
->icon('heroicon-o-lifebuoy')
|
||||||
|
->color('gray')
|
||||||
|
->modal()
|
||||||
|
->slideOver()
|
||||||
|
->stickyModalHeader()
|
||||||
|
->modalHeading('Support diagnostics')
|
||||||
|
->modalDescription('Redacted tenant context from existing records.')
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
|
||||||
|
->mountUsing(function (): void {
|
||||||
|
$this->auditTenantSupportDiagnosticsOpen();
|
||||||
|
})
|
||||||
|
->modalContent(fn (): View => view('filament.modals.support-diagnostic-bundle', [
|
||||||
|
'bundle' => $this->tenantSupportDiagnosticBundle(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
return UiEnforcement::forAction($action)
|
||||||
|
->requireCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function tenantSupportDiagnosticBundle(): array
|
||||||
|
{
|
||||||
|
$user = $this->resolveDashboardActor();
|
||||||
|
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||||
|
|
||||||
|
return app(SupportDiagnosticBundleBuilder::class)->forTenant($tenant, $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditTenantSupportDiagnosticsOpen(): void
|
||||||
|
{
|
||||||
|
$user = $this->resolveDashboardActor();
|
||||||
|
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||||
|
|
||||||
|
$this->recordSupportDiagnosticsOpened(
|
||||||
|
tenant: $tenant,
|
||||||
|
bundle: $this->tenantSupportDiagnosticBundle(),
|
||||||
|
user: $user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $bundle
|
||||||
|
*/
|
||||||
|
private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void
|
||||||
|
{
|
||||||
|
$auditKey = 'tenant:'.$tenant->getKey();
|
||||||
|
|
||||||
|
if (in_array($auditKey, $this->supportDiagnosticsAuditKeys, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(WorkspaceAuditLogger::class)->logSupportDiagnosticsOpened(
|
||||||
|
tenant: $tenant,
|
||||||
|
contextType: 'tenant',
|
||||||
|
bundle: $bundle,
|
||||||
|
actor: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
app(ProductTelemetryRecorder::class)->record(
|
||||||
|
eventName: ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED,
|
||||||
|
workspaceId: (int) $tenant->workspace_id,
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
subjectType: 'tenant',
|
||||||
|
subjectId: (int) $tenant->getKey(),
|
||||||
|
metadata: [
|
||||||
|
'source_surface' => 'tenant_dashboard',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$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.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||||
use App\Filament\Resources\ReviewPackResource;
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
@ -267,6 +268,20 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
|||||||
)->toArray();
|
)->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($record->tenant instanceof Tenant) {
|
||||||
|
$entries[] = RelatedContextEntry::available(
|
||||||
|
key: 'customer_review_workspace',
|
||||||
|
label: 'Customer workspace',
|
||||||
|
value: $record->tenant->name,
|
||||||
|
secondaryValue: 'Open the customer-safe review workspace prefiltered to this tenant.',
|
||||||
|
targetUrl: CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
|
||||||
|
targetKind: 'canonical_page',
|
||||||
|
priority: 30,
|
||||||
|
actionLabel: 'Open customer workspace',
|
||||||
|
contextBadge: 'Reporting',
|
||||||
|
)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
return $entries;
|
return $entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,17 +6,18 @@
|
|||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
||||||
use App\Filament\Widgets\Tenant\FindingStatsOverview;
|
use App\Filament\Widgets\Tenant\FindingStatsOverview;
|
||||||
use App\Jobs\BackfillFindingLifecycleJob;
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
use App\Services\Findings\FindingWorkflowService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||||
|
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\Rbac\UiTooltips;
|
use App\Support\Rbac\UiTooltips;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -107,83 +108,76 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
$actions = [];
|
$actions = [];
|
||||||
|
|
||||||
if ((bool) config('tenantpilot.allow_admin_maintenance_actions', false)) {
|
$actions[] = UiEnforcement::forAction(
|
||||||
$actions[] = UiEnforcement::forAction(
|
Actions\Action::make('backfill_lifecycle')
|
||||||
Actions\Action::make('backfill_lifecycle')
|
->label('Backfill findings lifecycle')
|
||||||
->label('Backfill findings lifecycle')
|
->icon('heroicon-o-wrench-screwdriver')
|
||||||
->icon('heroicon-o-wrench-screwdriver')
|
->color('gray')
|
||||||
->color('gray')
|
->requiresConfirmation()
|
||||||
->requiresConfirmation()
|
->modalHeading('Backfill findings lifecycle')
|
||||||
->modalHeading('Backfill findings lifecycle')
|
->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.')
|
||||||
->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.')
|
->action(function (FindingsLifecycleBackfillRunbookService $runbookService): void {
|
||||||
->action(function (OperationRunService $operationRuns): void {
|
$user = auth()->user();
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$opRun = $operationRuns->ensureRunWithIdentity(
|
try {
|
||||||
tenant: $tenant,
|
$opRun = $runbookService->start(
|
||||||
type: 'findings.lifecycle.backfill',
|
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||||
identityInputs: [
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'trigger' => 'backfill',
|
|
||||||
],
|
|
||||||
context: [
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'initiator_user_id' => (int) $user->getKey(),
|
|
||||||
],
|
|
||||||
initiator: $user,
|
initiator: $user,
|
||||||
|
reason: null,
|
||||||
|
source: 'tenant_ui',
|
||||||
);
|
);
|
||||||
|
} catch (OperationalControlBlockedException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->title($exception->title())
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
$runUrl = OperationRunLinks::view($opRun, $tenant);
|
throw new \Filament\Support\Exceptions\Halt;
|
||||||
|
}
|
||||||
|
|
||||||
if ($opRun->wasRecentlyCreated === false) {
|
$runUrl = OperationRunLinks::view($opRun, $tenant);
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
||||||
|
|
||||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url($runUrl),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$operationRuns->dispatchOrFail($opRun, function () use ($tenant, $user): void {
|
|
||||||
BackfillFindingLifecycleJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
workspaceId: (int) $tenant->workspace_id,
|
|
||||||
initiatorUserId: (int) $user->getKey(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
if ($opRun->wasRecentlyCreated === false) {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->body('The backfill will run in the background. You can continue working while it completes.')
|
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label('Open operation')
|
||||||
->url($runUrl),
|
->url($runUrl),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
})
|
|
||||||
)
|
return;
|
||||||
->preserveVisibility()
|
}
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
->apply();
|
|
||||||
}
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->body('The backfill will run in the background. You can continue working while it completes.')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('Open operation')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply();
|
||||||
|
|
||||||
$actions[] = UiEnforcement::forAction(
|
$actions[] = UiEnforcement::forAction(
|
||||||
Actions\Action::make('triage_all_matching')
|
Actions\Action::make('triage_all_matching')
|
||||||
|
|||||||
@ -18,7 +18,9 @@
|
|||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Rules\SkipOrUuidRule;
|
use App\Rules\SkipOrUuidRule;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Directory\EntraGroupLabelResolver;
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
@ -31,14 +33,18 @@
|
|||||||
use App\Services\Providers\ProviderOperationStartResult;
|
use App\Services\Providers\ProviderOperationStartResult;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\BackupQuality\BackupQualityResolver;
|
use App\Support\BackupQuality\BackupQualityResolver;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||||
|
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||||
|
use App\Support\OperationalControls\OperationalControlEvaluator;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\RestoreRunIdempotency;
|
use App\Support\RestoreRunIdempotency;
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
@ -1921,16 +1927,26 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
->executionSafetySnapshot($tenant, $user, $data)
|
->executionSafetySnapshot($tenant, $user, $data)
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
[$result, $restoreRun] = static::startQueuedRestoreExecution(
|
try {
|
||||||
tenant: $tenant,
|
[$result, $restoreRun] = static::startQueuedRestoreExecution(
|
||||||
backupSet: $backupSet,
|
tenant: $tenant,
|
||||||
selectedItemIds: $selectedItemIds,
|
backupSet: $backupSet,
|
||||||
preview: $preview,
|
selectedItemIds: $selectedItemIds,
|
||||||
metadata: $metadata,
|
preview: $preview,
|
||||||
groupMapping: $groupMapping,
|
metadata: $metadata,
|
||||||
actorEmail: $actorEmail,
|
groupMapping: $groupMapping,
|
||||||
actorName: $actorName,
|
actorEmail: $actorEmail,
|
||||||
);
|
actorName: $actorName,
|
||||||
|
);
|
||||||
|
} catch (OperationalControlBlockedException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->title($exception->title())
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
throw new \Filament\Support\Exceptions\Halt;
|
||||||
|
}
|
||||||
|
|
||||||
app(ProviderOperationStartResultPresenter::class)
|
app(ProviderOperationStartResultPresenter::class)
|
||||||
->notification(
|
->notification(
|
||||||
@ -1978,6 +1994,13 @@ private static function startQueuedRestoreExecution(
|
|||||||
$initiator = auth()->user();
|
$initiator = auth()->user();
|
||||||
$initiator = $initiator instanceof User ? $initiator : null;
|
$initiator = $initiator instanceof User ? $initiator : null;
|
||||||
|
|
||||||
|
static::guardRestoreExecutionOperationalControl(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: $selectedItemIds,
|
||||||
|
initiator: $initiator,
|
||||||
|
);
|
||||||
|
|
||||||
$queuedRestoreRun = null;
|
$queuedRestoreRun = null;
|
||||||
|
|
||||||
$dispatcher = function (OperationRun $run) use (
|
$dispatcher = function (OperationRun $run) use (
|
||||||
@ -2097,6 +2120,58 @@ private static function startQueuedRestoreExecution(
|
|||||||
return [$result, $queuedRestoreRun?->refresh()];
|
return [$result, $queuedRestoreRun?->refresh()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int>|null $selectedItemIds
|
||||||
|
*/
|
||||||
|
private static function guardRestoreExecutionOperationalControl(
|
||||||
|
Tenant $tenant,
|
||||||
|
BackupSet $backupSet,
|
||||||
|
?array $selectedItemIds,
|
||||||
|
?User $initiator,
|
||||||
|
): void {
|
||||||
|
$workspace = $tenant->workspace;
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
throw new \RuntimeException('Restore execution requires a workspace context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$decision = app(OperationalControlEvaluator::class)->evaluate('restore.execute', $workspace);
|
||||||
|
|
||||||
|
if (! $decision->isPaused()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(WorkspaceAuditLogger::class)->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::OperationalControlExecutionBlocked,
|
||||||
|
context: [
|
||||||
|
'metadata' => array_filter([
|
||||||
|
'control_key' => $decision->controlKey,
|
||||||
|
'scope_type' => $decision->matchedScopeType,
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'reason_text' => $decision->reasonText,
|
||||||
|
'expires_at' => $decision->expiresAt?->toIso8601String(),
|
||||||
|
'actor_id' => $initiator?->getKey(),
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'selected_item_count' => is_array($selectedItemIds) ? count($selectedItemIds) : null,
|
||||||
|
'requested_scope' => 'restore.execute',
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||||
|
],
|
||||||
|
actor: $initiator,
|
||||||
|
status: 'blocked',
|
||||||
|
resourceType: 'operational_control',
|
||||||
|
resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null,
|
||||||
|
targetLabel: OperationCatalog::label('restore.execute'),
|
||||||
|
summary: 'Restore execution blocked by operational control',
|
||||||
|
tenant: $tenant,
|
||||||
|
);
|
||||||
|
|
||||||
|
throw OperationalControlBlockedException::forDecision(
|
||||||
|
decision: $decision,
|
||||||
|
actionLabel: OperationCatalog::label('restore.execute'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int>|null $selectedItemIds
|
* @param array<int>|null $selectedItemIds
|
||||||
*/
|
*/
|
||||||
@ -2529,16 +2604,26 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
|||||||
|
|
||||||
$metadata['rerun_of_restore_run_id'] = $record->id;
|
$metadata['rerun_of_restore_run_id'] = $record->id;
|
||||||
|
|
||||||
[$result, $newRun] = static::startQueuedRestoreExecution(
|
try {
|
||||||
tenant: $tenant,
|
[$result, $newRun] = static::startQueuedRestoreExecution(
|
||||||
backupSet: $backupSet,
|
tenant: $tenant,
|
||||||
selectedItemIds: $selectedItemIds,
|
backupSet: $backupSet,
|
||||||
preview: $preview,
|
selectedItemIds: $selectedItemIds,
|
||||||
metadata: $metadata,
|
preview: $preview,
|
||||||
groupMapping: $groupMapping,
|
metadata: $metadata,
|
||||||
actorEmail: $actorEmail,
|
groupMapping: $groupMapping,
|
||||||
actorName: $actorName,
|
actorEmail: $actorEmail,
|
||||||
);
|
actorName: $actorName,
|
||||||
|
);
|
||||||
|
} catch (OperationalControlBlockedException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->title($exception->title())
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
|
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|||||||
@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
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\Pages\Reviews\CustomerReviewWorkspace;
|
||||||
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;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
@ -10,6 +13,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
@ -45,6 +49,8 @@
|
|||||||
|
|
||||||
class ReviewPackResource extends Resource
|
class ReviewPackResource extends Resource
|
||||||
{
|
{
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static ?string $model = ReviewPack::class;
|
protected static ?string $model = ReviewPack::class;
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
@ -102,9 +108,9 @@ public static function canView(Model $record): bool
|
|||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action appears in the list header once review packs exist.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state carries the single Generate CTA while the registry is empty.')
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download remains the only direct row shortcut and Expire is grouped under More.')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download remains the only direct row shortcut and Expire is grouped under More.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
||||||
@ -190,6 +196,13 @@ public static function infolist(Schema $schema): Schema
|
|||||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant)
|
? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant)
|
||||||
: null)
|
: null)
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
|
TextEntry::make('customer_workspace')
|
||||||
|
->label('Customer workspace')
|
||||||
|
->state(fn (): string => 'Open workspace')
|
||||||
|
->url(fn (ReviewPack $record): ?string => $record->tenant instanceof Tenant
|
||||||
|
? CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant)
|
||||||
|
: null)
|
||||||
|
->placeholder('—'),
|
||||||
TextEntry::make('summary.review_status')
|
TextEntry::make('summary.review_status')
|
||||||
->label('Review status')
|
->label('Review status')
|
||||||
->badge()
|
->badge()
|
||||||
@ -350,41 +363,62 @@ public static function table(Table $table): Table
|
|||||||
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
|
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
|
||||||
->emptyStateIcon('heroicon-o-document-arrow-down')
|
->emptyStateIcon('heroicon-o-document-arrow-down')
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
UiEnforcement::forAction(
|
static::generatePackAction(name: 'generate_first', label: 'Generate first pack'),
|
||||||
Actions\Action::make('generate_first')
|
|
||||||
->label('Generate first pack')
|
|
||||||
->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 = Filament::getTenant();
|
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
|
return parent::getEloquentQuery()
|
||||||
|
->with(['tenant', 'operationRun', 'evidenceSnapshot', 'tenantReview'])
|
||||||
|
->where('tenant_id', (int) $tenant->getKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
@ -458,6 +492,14 @@ public static function executeGeneration(array $data): void
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$reviewPack = $service->generate($tenant, $user, $options);
|
$reviewPack = $service->generate($tenant, $user, $options);
|
||||||
|
} catch (WorkspaceEntitlementBlockedException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->warning()
|
||||||
|
->title('Review pack generation unavailable')
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
} catch (ReviewPackEvidenceResolutionException $exception) {
|
} catch (ReviewPackEvidenceResolutionException $exception) {
|
||||||
$reasons = $exception->result->reasons;
|
$reasons = $exception->result->reasons;
|
||||||
|
|
||||||
@ -493,4 +535,55 @@ public static function executeGeneration(array $data): void
|
|||||||
|
|
||||||
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
|
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
|
||||||
|
{
|
||||||
|
$tenant ??= Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function currentTenantContext(): ?Tenant
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant ? $tenant : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
|
||||||
|
{
|
||||||
|
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
|
||||||
|
{
|
||||||
|
$decision = static::reviewPackGenerationDecision($tenant);
|
||||||
|
|
||||||
|
if (! (bool) ($decision['is_blocked'] ?? false)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = $decision['block_reason'] ?? null;
|
||||||
|
|
||||||
|
return is_string($reason) && $reason !== '' ? $reason : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
|
||||||
|
{
|
||||||
|
$tenant ??= static::currentTenantContext();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant)) {
|
||||||
|
return AuthUiTooltips::insufficientPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::reviewPackGenerationBlockReason($tenant);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,7 @@
|
|||||||
namespace App\Filament\Resources\ReviewPackResource\Pages;
|
namespace App\Filament\Resources\ReviewPackResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\ReviewPackResource;
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Forms\Components\Toggle;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
|
|
||||||
class ListReviewPacks extends ListRecords
|
class ListReviewPacks extends ListRecords
|
||||||
{
|
{
|
||||||
@ -17,29 +12,13 @@ class ListReviewPacks extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
UiEnforcement::forAction(
|
ReviewPackResource::generatePackAction()
|
||||||
Actions\Action::make('generate_pack')
|
->visible(fn (): bool => $this->tableHasRecords()),
|
||||||
->label('Generate Pack')
|
|
||||||
->icon('heroicon-o-plus')
|
|
||||||
->action(function (array $data): void {
|
|
||||||
ReviewPackResource::executeGeneration($data);
|
|
||||||
})
|
|
||||||
->form([
|
|
||||||
Section::make('Pack options')
|
|
||||||
->schema([
|
|
||||||
Toggle::make('include_pii')
|
|
||||||
->label('Include PII')
|
|
||||||
->helperText('Include personally identifiable information in the export.')
|
|
||||||
->default(config('tenantpilot.review_pack.include_pii_default', true)),
|
|
||||||
Toggle::make('include_operations')
|
|
||||||
->label('Include operations')
|
|
||||||
->helperText('Include recent operation history in the export.')
|
|
||||||
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function tableHasRecords(): bool
|
||||||
|
{
|
||||||
|
return $this->getTableRecords()->count() > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,51 @@ 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')
|
||||||
@ -28,46 +73,7 @@ protected function getHeaderActions(): array
|
|||||||
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
|
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
$regenerateAction,
|
||||||
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(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||||
use App\Filament\Resources\TenantReviewResource\Pages;
|
use App\Filament\Resources\TenantReviewResource\Pages;
|
||||||
|
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
@ -15,6 +17,7 @@
|
|||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
use App\Services\TenantReviews\TenantReviewService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
@ -241,6 +244,25 @@ public static function infolist(Schema $schema): Schema
|
|||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
|
$exportExecutivePackAction = UiEnforcement::forTableAction(
|
||||||
|
Actions\Action::make('export_executive_pack')
|
||||||
|
->label('Export executive pack')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
||||||
|
TenantReviewStatus::Ready->value,
|
||||||
|
TenantReviewStatus::Published->value,
|
||||||
|
], true))
|
||||||
|
->disabled(fn (TenantReview $record): bool => static::reviewPackGenerationBlocked($record->tenant))
|
||||||
|
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
|
||||||
|
fn (TenantReview $record): TenantReview => $record,
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->preserveDisabled()
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
$exportExecutivePackAction->tooltip(fn (TenantReview $record): ?string => static::reviewPackGenerationActionTooltip($record->tenant));
|
||||||
|
|
||||||
return $table
|
return $table
|
||||||
->defaultSort('generated_at', 'desc')
|
->defaultSort('generated_at', 'desc')
|
||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
@ -287,20 +309,7 @@ public static function table(Table $table): Table
|
|||||||
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
UiEnforcement::forTableAction(
|
$exportExecutivePackAction,
|
||||||
Actions\Action::make('export_executive_pack')
|
|
||||||
->label('Export executive pack')
|
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
|
||||||
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
|
||||||
TenantReviewStatus::Ready->value,
|
|
||||||
TenantReviewStatus::Published->value,
|
|
||||||
], true))
|
|
||||||
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
|
|
||||||
fn (TenantReview $record): TenantReview => $record,
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No tenant reviews yet')
|
->emptyStateHeading('No tenant reviews yet')
|
||||||
@ -423,6 +432,50 @@ public static function executeCreateReview(array $data): void
|
|||||||
$toast->send();
|
$toast->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
|
||||||
|
{
|
||||||
|
$tenant ??= Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
|
||||||
|
{
|
||||||
|
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
|
||||||
|
{
|
||||||
|
$decision = static::reviewPackGenerationDecision($tenant);
|
||||||
|
|
||||||
|
if (! (bool) ($decision['is_blocked'] ?? false)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = $decision['block_reason'] ?? null;
|
||||||
|
|
||||||
|
return is_string($reason) && $reason !== '' ? $reason : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
|
||||||
|
{
|
||||||
|
$tenant ??= static::panelTenantContext();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
|
||||||
|
return AuthUiTooltips::insufficientPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::reviewPackGenerationBlockReason($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
public static function executeExport(TenantReview $review): void
|
public static function executeExport(TenantReview $review): void
|
||||||
{
|
{
|
||||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
||||||
@ -457,6 +510,10 @@ public static function executeExport(TenantReview $review): void
|
|||||||
'include_pii' => true,
|
'include_pii' => true,
|
||||||
'include_operations' => true,
|
'include_operations' => true,
|
||||||
]);
|
]);
|
||||||
|
} catch (WorkspaceEntitlementBlockedException $exception) {
|
||||||
|
Notification::make()->warning()->title('Executive pack export unavailable')->body($exception->getMessage())->send();
|
||||||
|
|
||||||
|
return;
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
|
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
|
||||||
|
|
||||||
@ -593,6 +650,15 @@ private static function summaryContextLinks(TenantReview $record): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($record->tenant) {
|
||||||
|
$links[] = [
|
||||||
|
'title' => 'Customer workspace',
|
||||||
|
'label' => 'Open customer workspace',
|
||||||
|
'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
|
||||||
|
'description' => 'Open the customer-safe review workspace prefiltered to this tenant.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if ($record->evidenceSnapshot && $record->tenant) {
|
if ($record->evidenceSnapshot && $record->tenant) {
|
||||||
$links[] = [
|
$links[] = [
|
||||||
'title' => 'Evidence snapshot',
|
'title' => 'Evidence snapshot',
|
||||||
|
|||||||
@ -4,12 +4,15 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\TenantReviewResource\Pages;
|
namespace App\Filament\Resources\TenantReviewResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
use App\Services\TenantReviews\TenantReviewService;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\TenantReviewStatus;
|
||||||
@ -24,6 +27,13 @@ class ViewTenantReview extends ViewRecord
|
|||||||
{
|
{
|
||||||
protected static string $resource = TenantReviewResource::class;
|
protected static string $resource = TenantReviewResource::class;
|
||||||
|
|
||||||
|
public function mount(int|string $record): void
|
||||||
|
{
|
||||||
|
parent::mount($record);
|
||||||
|
|
||||||
|
$this->auditCustomerWorkspaceOpen();
|
||||||
|
}
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
protected function resolveRecord(int|string $key): Model
|
||||||
{
|
{
|
||||||
return TenantReviewResource::resolveScopedRecordOrFail($key);
|
return TenantReviewResource::resolveScopedRecordOrFail($key);
|
||||||
@ -69,7 +79,7 @@ protected function getHeaderActions(): array
|
|||||||
->label('Danger')
|
->label('Danger')
|
||||||
->icon('heroicon-o-archive-box')
|
->icon('heroicon-o-archive-box')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
|
->visible(fn (): bool => ! $this->isCustomerWorkspaceView() && ! $this->record->statusEnum()->isTerminal()),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,6 +95,10 @@ private function primaryLifecycleAction(): ?Actions\Action
|
|||||||
|
|
||||||
private function primaryLifecycleActionName(): ?string
|
private function primaryLifecycleActionName(): ?string
|
||||||
{
|
{
|
||||||
|
if ($this->isCustomerWorkspaceView()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
|
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
|
||||||
return 'export_executive_pack';
|
return 'export_executive_pack';
|
||||||
}
|
}
|
||||||
@ -122,6 +136,10 @@ private function secondaryLifecycleActions(): array
|
|||||||
*/
|
*/
|
||||||
private function secondaryLifecycleActionNames(): array
|
private function secondaryLifecycleActionNames(): array
|
||||||
{
|
{
|
||||||
|
if ($this->isCustomerWorkspaceView()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$names = [];
|
$names = [];
|
||||||
|
|
||||||
if ($this->record->isMutable()) {
|
if ($this->record->isMutable()) {
|
||||||
@ -178,7 +196,6 @@ private function refreshReviewAction(): Actions\Action
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
|
||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -232,7 +249,7 @@ private function publishReviewAction(): Actions\Action
|
|||||||
|
|
||||||
private function exportExecutivePackAction(): Actions\Action
|
private function exportExecutivePackAction(): Actions\Action
|
||||||
{
|
{
|
||||||
return UiEnforcement::forAction(
|
$action = UiEnforcement::forAction(
|
||||||
Actions\Action::make('export_executive_pack')
|
Actions\Action::make('export_executive_pack')
|
||||||
->label('Export executive pack')
|
->label('Export executive pack')
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
@ -241,11 +258,17 @@ private function exportExecutivePackAction(): Actions\Action
|
|||||||
TenantReviewStatus::Ready->value,
|
TenantReviewStatus::Ready->value,
|
||||||
TenantReviewStatus::Published->value,
|
TenantReviewStatus::Published->value,
|
||||||
], true))
|
], true))
|
||||||
|
->disabled(fn (): bool => TenantReviewResource::reviewPackGenerationBlocked($this->record->tenant))
|
||||||
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
|
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
|
->preserveDisabled()
|
||||||
->apply();
|
->apply();
|
||||||
|
|
||||||
|
$action->tooltip(fn (): ?string => TenantReviewResource::reviewPackGenerationActionTooltip($this->record->tenant));
|
||||||
|
|
||||||
|
return $action;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createNextReviewAction(): Actions\Action
|
private function createNextReviewAction(): Actions\Action
|
||||||
@ -319,4 +342,39 @@ private function archiveReviewAction(): Actions\Action
|
|||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isCustomerWorkspaceView(): bool
|
||||||
|
{
|
||||||
|
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditCustomerWorkspaceOpen(): void
|
||||||
|
{
|
||||||
|
if (! $this->isCustomerWorkspaceView()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = $this->record->tenant;
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(WorkspaceAuditLogger::class)->log(
|
||||||
|
workspace: $tenant->workspace,
|
||||||
|
action: AuditActionId::TenantReviewOpened,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'review_id' => (int) $this->record->getKey(),
|
||||||
|
'source_surface' => 'customer_review_workspace',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actor: $user,
|
||||||
|
resourceType: 'tenant_review',
|
||||||
|
resourceId: (string) $this->record->getKey(),
|
||||||
|
targetLabel: sprintf('Tenant review #%d', (int) $this->record->getKey()),
|
||||||
|
tenant: $tenant,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,9 @@
|
|||||||
|
|
||||||
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\ControlTowerRecentFailures;
|
use App\Filament\System\Widgets\ControlTowerRecentFailures;
|
||||||
use App\Filament\System\Widgets\ControlTowerTopOffenders;
|
use App\Filament\System\Widgets\ControlTowerTopOffenders;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
@ -61,9 +64,24 @@ public function getWidgets(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
ControlTowerHealthIndicator::class,
|
ControlTowerHealthIndicator::class,
|
||||||
ControlTowerKpis::class,
|
new WidgetConfiguration(CustomerHealthKpis::class, [
|
||||||
ControlTowerTopOffenders::class,
|
'window' => $this->window,
|
||||||
ControlTowerRecentFailures::class,
|
]),
|
||||||
|
new WidgetConfiguration(CustomerHealthTopWorkspaces::class, [
|
||||||
|
'window' => $this->window,
|
||||||
|
]),
|
||||||
|
new WidgetConfiguration(ControlTowerKpis::class, [
|
||||||
|
'window' => $this->window,
|
||||||
|
]),
|
||||||
|
new WidgetConfiguration(ProductTelemetryKpis::class, [
|
||||||
|
'window' => $this->window,
|
||||||
|
]),
|
||||||
|
new WidgetConfiguration(ControlTowerTopOffenders::class, [
|
||||||
|
'window' => $this->window,
|
||||||
|
]),
|
||||||
|
new WidgetConfiguration(ControlTowerRecentFailures::class, [
|
||||||
|
'window' => $this->window,
|
||||||
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\System\Pages\Directory\Concerns;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\CustomerHealth\CustomerHealthDimensionCatalog;
|
||||||
|
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||||
|
|
||||||
|
trait BuildsCustomerHealthDecisionData
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* overall_level: string,
|
||||||
|
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
||||||
|
* dominant_dimension_keys: list<string>
|
||||||
|
* } $summary
|
||||||
|
* @return array{
|
||||||
|
* overall: array{label: string, color: string, icon: string|null},
|
||||||
|
* reason: string,
|
||||||
|
* impact: string,
|
||||||
|
* recommended_action: string,
|
||||||
|
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
|
||||||
|
* window_label: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
protected function buildCustomerHealthDecision(array $summary, SystemConsoleWindow $window, string $surface): array
|
||||||
|
{
|
||||||
|
$overallBadge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $summary['overall_level']);
|
||||||
|
|
||||||
|
$dominantDimensions = collect($summary['dominant_dimension_keys'])
|
||||||
|
->map(function (string $dimensionKey) use ($summary): ?array {
|
||||||
|
$dimension = $summary['dimensions'][$dimensionKey] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($dimension)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$badge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $dimension['level']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => $dimension['label'],
|
||||||
|
'color' => $badge->color,
|
||||||
|
'icon' => $badge->icon,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->filter()
|
||||||
|
->take(2)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$dominantLabels = array_map(static fn (array $dimension): string => $dimension['label'], $dominantDimensions);
|
||||||
|
$primaryDimension = $summary['dominant_dimension_keys'][0] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'overall' => [
|
||||||
|
'label' => $overallBadge->label,
|
||||||
|
'color' => $overallBadge->color,
|
||||||
|
'icon' => $overallBadge->icon,
|
||||||
|
],
|
||||||
|
'reason' => $this->customerHealthReason($dominantLabels),
|
||||||
|
'impact' => $this->customerHealthImpact($summary['overall_level'], $primaryDimension),
|
||||||
|
'recommended_action' => $this->customerHealthRecommendedAction($summary['overall_level'], $primaryDimension, $surface),
|
||||||
|
'dominant_dimensions' => $dominantDimensions,
|
||||||
|
'window_label' => SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $dominantLabels
|
||||||
|
*/
|
||||||
|
protected function customerHealthReason(array $dominantLabels): string
|
||||||
|
{
|
||||||
|
if ($dominantLabels === []) {
|
||||||
|
return 'No active health drivers are pressuring this workspace right now.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$labelPrefix = count($dominantLabels) === 1 ? 'Top driver' : 'Top drivers';
|
||||||
|
|
||||||
|
return $labelPrefix.': '.implode(', ', $dominantLabels);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function customerHealthImpact(string $overallLevel, ?string $primaryDimension): string
|
||||||
|
{
|
||||||
|
if ($overallLevel === 'ok') {
|
||||||
|
return 'Tracked onboarding, provider, operational, governance, review-pack, and engagement signals are currently stable.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($overallLevel === 'unknown') {
|
||||||
|
return 'Some required health truth is missing or stale, so this workspace cannot be treated as healthy yet.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($primaryDimension) {
|
||||||
|
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $overallLevel === 'critical'
|
||||||
|
? 'Onboarding readiness is blocked, so this workspace cannot be treated as operationally ready.'
|
||||||
|
: 'Onboarding readiness still needs follow-up before this workspace can be treated as fully stable.',
|
||||||
|
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $overallLevel === 'critical'
|
||||||
|
? 'Default provider consent or verification is blocking reliable tenant management.'
|
||||||
|
: 'Provider connectivity has degraded and may impact reliable tenant management.',
|
||||||
|
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => $overallLevel === 'critical'
|
||||||
|
? 'Failed or stuck operations are actively putting delivery at risk.'
|
||||||
|
: 'Recent operational noise is starting to erode delivery confidence.',
|
||||||
|
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $overallLevel === 'critical'
|
||||||
|
? 'High-severity or expired governance pressure needs immediate review.'
|
||||||
|
: 'Governance pressure is active and should be reviewed before it escalates.',
|
||||||
|
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => $overallLevel === 'critical'
|
||||||
|
? 'Recent review-pack work is unusable or expired, so review readiness is blocked.'
|
||||||
|
: 'Review-pack readiness is incomplete, so recent review evidence may not be usable yet.',
|
||||||
|
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $overallLevel === 'critical'
|
||||||
|
? 'Recent product activity is missing, which suggests active usage may be deteriorating.'
|
||||||
|
: 'Recent product activity is thinning out and may indicate adoption drift.',
|
||||||
|
default => $overallLevel === 'critical'
|
||||||
|
? 'This workspace needs immediate operator follow-up.'
|
||||||
|
: 'This workspace needs follow-up soon to prevent further drift.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function customerHealthRecommendedAction(string $overallLevel, ?string $primaryDimension, string $surface): string
|
||||||
|
{
|
||||||
|
if ($overallLevel === 'ok') {
|
||||||
|
return 'Continue normal monitoring from the system dashboard.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($primaryDimension) {
|
||||||
|
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $surface === 'tenant'
|
||||||
|
? 'Confirm the tenant onboarding state with the responsible tenant admin and clear the blocking step.'
|
||||||
|
: 'Open the affected tenant below and confirm which onboarding step is blocked.',
|
||||||
|
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $surface === 'tenant'
|
||||||
|
? 'Review connectivity signals below and confirm the default provider consent and verification state.'
|
||||||
|
: 'Open the affected tenant below and review the default provider connection state.',
|
||||||
|
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => 'Review recent operations below and triage failed or stuck runs first.',
|
||||||
|
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $surface === 'tenant'
|
||||||
|
? 'Review governance findings or exception pressure for this tenant before proceeding.'
|
||||||
|
: 'Open the affected tenant below and review governance findings or exceptions.',
|
||||||
|
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => 'Check recent review-pack activity and confirm that a usable pack exists for the current window.',
|
||||||
|
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $surface === 'tenant'
|
||||||
|
? 'Confirm whether missing recent product activity is expected for this tenant.'
|
||||||
|
: 'Confirm whether missing recent product activity is expected across this workspace.',
|
||||||
|
default => 'Review the diagnostics below to confirm which source truth needs operator follow-up.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,20 +4,25 @@
|
|||||||
|
|
||||||
namespace App\Filament\System\Pages\Directory;
|
namespace App\Filament\System\Pages\Directory;
|
||||||
|
|
||||||
|
use App\Filament\System\Pages\Directory\Concerns\BuildsCustomerHealthDecisionData;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantPermission;
|
use App\Models\TenantPermission;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
use App\Support\System\SystemDirectoryLinks;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
|
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class ViewTenant extends Page
|
class ViewTenant extends Page
|
||||||
{
|
{
|
||||||
|
use BuildsCustomerHealthDecisionData;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static ?string $slug = 'directory/tenants/{tenant}';
|
protected static ?string $slug = 'directory/tenants/{tenant}';
|
||||||
@ -102,4 +107,26 @@ public function runsUrl(): string
|
|||||||
{
|
{
|
||||||
return SystemOperationRunLinks::index();
|
return SystemOperationRunLinks::index();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* overall: array{label: string, color: string, icon: string|null},
|
||||||
|
* reason: string,
|
||||||
|
* impact: string,
|
||||||
|
* recommended_action: string,
|
||||||
|
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
|
||||||
|
* window_label: string
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public function customerHealthDecision(): ?array
|
||||||
|
{
|
||||||
|
$window = SystemConsoleWindow::fromNullable(request()->query('window'));
|
||||||
|
$summary = app(WorkspaceHealthSummaryQuery::class)->summaryForWorkspace((int) $this->tenant->workspace_id, $window);
|
||||||
|
|
||||||
|
if (! is_array($summary)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->buildCustomerHealthDecision($summary, $window, 'tenant');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,19 +4,25 @@
|
|||||||
|
|
||||||
namespace App\Filament\System\Pages\Directory;
|
namespace App\Filament\System\Pages\Directory;
|
||||||
|
|
||||||
|
use App\Filament\System\Pages\Directory\Concerns\BuildsCustomerHealthDecisionData;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
use App\Support\System\SystemDirectoryLinks;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
|
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class ViewWorkspace extends Page
|
class ViewWorkspace extends Page
|
||||||
{
|
{
|
||||||
|
use BuildsCustomerHealthDecisionData;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static ?string $slug = 'directory/workspaces/{workspace}';
|
protected static ?string $slug = 'directory/workspaces/{workspace}';
|
||||||
@ -79,4 +85,34 @@ public function runsUrl(): string
|
|||||||
{
|
{
|
||||||
return SystemOperationRunLinks::index();
|
return SystemOperationRunLinks::index();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function workspaceEntitlementSummary(): array
|
||||||
|
{
|
||||||
|
return app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* overall: array{label: string, color: string, icon: string|null},
|
||||||
|
* reason: string,
|
||||||
|
* impact: string,
|
||||||
|
* recommended_action: string,
|
||||||
|
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
|
||||||
|
* window_label: string
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public function customerHealthDecision(): ?array
|
||||||
|
{
|
||||||
|
$window = SystemConsoleWindow::fromNullable(request()->query('window'));
|
||||||
|
$summary = app(WorkspaceHealthSummaryQuery::class)->summaryForWorkspace($this->workspace, $window);
|
||||||
|
|
||||||
|
if (! is_array($summary)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->buildCustomerHealthDecision($summary, $window, 'workspace');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
693
apps/platform/app/Filament/System/Pages/Ops/Controls.php
Normal file
693
apps/platform/app/Filament/System/Pages/Ops/Controls.php
Normal file
@ -0,0 +1,693 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\System\Pages\Ops;
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\OperationalControlActivation;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Audit\AuditRecorder;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Audit\AuditActorSnapshot;
|
||||||
|
use App\Support\Audit\AuditTargetSnapshot;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\OperationalControls\OperationalControlCatalog;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
|
use Filament\Forms\Components\Radio;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class Controls extends Page
|
||||||
|
{
|
||||||
|
protected static ?string $navigationLabel = 'Controls';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Operational Controls';
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-pause-circle';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'ops/controls';
|
||||||
|
|
||||||
|
protected string $view = 'filament.system.pages.ops.controls';
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $user instanceof PlatformUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->hasCapability(PlatformCapabilities::ACCESS_SYSTEM_PANEL)
|
||||||
|
&& $user->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
abort_unless(static::canAccess(), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeader(): ?View
|
||||||
|
{
|
||||||
|
return view('filament.system.pages.ops.partials.controls-header', [
|
||||||
|
'breadcrumbs' => filament()->hasBreadcrumbs() ? $this->getBreadcrumbs() : [],
|
||||||
|
'heading' => $this->getHeading(),
|
||||||
|
'subheading' => $this->getSubheading(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$this->pauseRestoreExecuteAction(),
|
||||||
|
$this->resumeRestoreExecuteAction(),
|
||||||
|
$this->viewHistoryRestoreExecuteAction(),
|
||||||
|
$this->pauseAiExecutionAction(),
|
||||||
|
$this->resumeAiExecutionAction(),
|
||||||
|
$this->viewHistoryAiExecutionAction(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function controlCards(): array
|
||||||
|
{
|
||||||
|
$catalog = app(OperationalControlCatalog::class);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
fn (string $controlKey): array => $this->controlSummary($controlKey),
|
||||||
|
$catalog->keys(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function controlSummary(string $controlKey): array
|
||||||
|
{
|
||||||
|
$definition = app(OperationalControlCatalog::class)->definition($controlKey);
|
||||||
|
$activations = $this->activeActivationsForControl($controlKey);
|
||||||
|
|
||||||
|
$effectiveState = $activations->isEmpty() ? 'enabled' : 'paused';
|
||||||
|
$stateLabel = match (true) {
|
||||||
|
$activations->contains(fn (OperationalControlActivation $activation): bool => $activation->scope_type === 'global') => 'Paused globally',
|
||||||
|
$activations->isNotEmpty() => sprintf('Workspace pauses active (%d)', $activations->where('scope_type', 'workspace')->count()),
|
||||||
|
default => 'Enabled',
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'control_key' => $controlKey,
|
||||||
|
'action_slug' => $this->actionSlug($controlKey),
|
||||||
|
'label' => (string) $definition['label'],
|
||||||
|
'effective_state' => $effectiveState,
|
||||||
|
'state_label' => $stateLabel,
|
||||||
|
'supported_scopes' => $definition['supported_scopes'],
|
||||||
|
'affected_surfaces' => $definition['affected_surfaces'],
|
||||||
|
'active_activations' => $activations
|
||||||
|
->map(fn (OperationalControlActivation $activation): array => $this->activationSummary($activation))
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
'history_count' => $this->recentAuditEventsForControl($controlKey)->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{control_key: string, scope_type: string, workspace_id: ?int, workspace_count: int, tenant_count: int, summary: string}
|
||||||
|
*/
|
||||||
|
public function scopeImpactPreview(string $controlKey, string $scopeType, ?int $workspaceId): array
|
||||||
|
{
|
||||||
|
$label = app(OperationalControlCatalog::class)->label($controlKey);
|
||||||
|
|
||||||
|
if ($scopeType === 'workspace') {
|
||||||
|
$workspace = is_int($workspaceId)
|
||||||
|
? Workspace::query()->whereKey($workspaceId)->first()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return [
|
||||||
|
'control_key' => $controlKey,
|
||||||
|
'scope_type' => $scopeType,
|
||||||
|
'workspace_id' => null,
|
||||||
|
'workspace_count' => 0,
|
||||||
|
'tenant_count' => 0,
|
||||||
|
'summary' => 'Select a workspace to preview the scope impact.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantCount = Tenant::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('external_id', '!=', 'platform')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'control_key' => $controlKey,
|
||||||
|
'scope_type' => $scopeType,
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'workspace_count' => 1,
|
||||||
|
'tenant_count' => $tenantCount,
|
||||||
|
'summary' => sprintf('%s will affect workspace %s and %d %s.', $label, $workspace->name, $tenantCount, $tenantCount === 1 ? 'tenant' : 'tenants'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantCount = Tenant::query()
|
||||||
|
->where('external_id', '!=', 'platform')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$workspaceCount = Tenant::query()
|
||||||
|
->where('external_id', '!=', 'platform')
|
||||||
|
->distinct('workspace_id')
|
||||||
|
->count('workspace_id');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'control_key' => $controlKey,
|
||||||
|
'scope_type' => 'global',
|
||||||
|
'workspace_id' => null,
|
||||||
|
'workspace_count' => $workspaceCount,
|
||||||
|
'tenant_count' => $tenantCount,
|
||||||
|
'summary' => sprintf('%s will affect %d %s across %d %s.', $label, $workspaceCount, $workspaceCount === 1 ? 'workspace' : 'workspaces', $tenantCount, $tenantCount === 1 ? 'tenant' : 'tenants'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pauseRestoreExecuteAction(): Action
|
||||||
|
{
|
||||||
|
return $this->pauseActionFor('restore.execute');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resumeRestoreExecuteAction(): Action
|
||||||
|
{
|
||||||
|
return $this->resumeActionFor('restore.execute');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewHistoryRestoreExecuteAction(): Action
|
||||||
|
{
|
||||||
|
return $this->historyActionFor('restore.execute');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pauseAiExecutionAction(): Action
|
||||||
|
{
|
||||||
|
return $this->pauseActionFor('ai.execution');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resumeAiExecutionAction(): Action
|
||||||
|
{
|
||||||
|
return $this->resumeActionFor('ai.execution');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewHistoryAiExecutionAction(): Action
|
||||||
|
{
|
||||||
|
return $this->historyActionFor('ai.execution');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pauseActionFor(string $controlKey): Action
|
||||||
|
{
|
||||||
|
$label = app(OperationalControlCatalog::class)->label($controlKey);
|
||||||
|
|
||||||
|
return Action::make('pause_'.$this->actionSlug($controlKey))
|
||||||
|
->label('Pause '.$label)
|
||||||
|
->icon('heroicon-o-pause')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Pause '.$label)
|
||||||
|
->modalDescription('Review the scope impact, reason, and optional expiry before confirming this control change.')
|
||||||
|
->form($this->pauseFormSchema($controlKey))
|
||||||
|
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
|
||||||
|
$actor = $this->controlsActor();
|
||||||
|
[$scopeType, $workspace, $reasonText, $expiresAt] = $this->normalizePauseInput($controlKey, $data);
|
||||||
|
|
||||||
|
$scopeQuery = $this->activationScopeQuery($controlKey, $scopeType, $workspace);
|
||||||
|
|
||||||
|
(clone $scopeQuery)
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '<=', now())
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$activation = (clone $scopeQuery)->notExpired()->first();
|
||||||
|
$auditAction = $activation instanceof OperationalControlActivation
|
||||||
|
? AuditActionId::OperationalControlUpdated
|
||||||
|
: AuditActionId::OperationalControlPaused;
|
||||||
|
|
||||||
|
if ($activation instanceof OperationalControlActivation) {
|
||||||
|
$activation->fill([
|
||||||
|
'reason_text' => $reasonText,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'updated_by_platform_user_id' => (int) $actor->getKey(),
|
||||||
|
])->save();
|
||||||
|
} else {
|
||||||
|
$activation = OperationalControlActivation::query()->create([
|
||||||
|
'control_key' => $controlKey,
|
||||||
|
'scope_type' => $scopeType,
|
||||||
|
'workspace_id' => $workspace instanceof Workspace ? (int) $workspace->getKey() : null,
|
||||||
|
'reason_text' => $reasonText,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'created_by_platform_user_id' => (int) $actor->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->recordControlMutation(
|
||||||
|
auditAction: $auditAction,
|
||||||
|
activation: $activation,
|
||||||
|
actor: $actor,
|
||||||
|
auditRecorder: $auditRecorder,
|
||||||
|
workspaceAuditLogger: $workspaceAuditLogger,
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(sprintf('%s %s', $label, $auditAction === AuditActionId::OperationalControlPaused ? 'paused' : 'updated'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resumeActionFor(string $controlKey): Action
|
||||||
|
{
|
||||||
|
$label = app(OperationalControlCatalog::class)->label($controlKey);
|
||||||
|
|
||||||
|
return Action::make('resume_'.$this->actionSlug($controlKey))
|
||||||
|
->label('Resume '.$label)
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Resume '.$label)
|
||||||
|
->modalDescription('Remove the selected pause so new starts can proceed again.')
|
||||||
|
->form($this->resumeFormSchema($controlKey))
|
||||||
|
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
|
||||||
|
$actor = $this->controlsActor();
|
||||||
|
[$scopeType, $workspace] = $this->normalizeResumeInput($controlKey, $data);
|
||||||
|
|
||||||
|
$activation = $this->activationScopeQuery($controlKey, $scopeType, $workspace)
|
||||||
|
->notExpired()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $activation instanceof OperationalControlActivation) {
|
||||||
|
Notification::make()
|
||||||
|
->title(sprintf('%s already enabled', $label))
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$activationSnapshot = $activation->replicate();
|
||||||
|
$activationSnapshot->forceFill($activation->getAttributes());
|
||||||
|
$activation->delete();
|
||||||
|
|
||||||
|
$this->recordControlMutation(
|
||||||
|
auditAction: AuditActionId::OperationalControlResumed,
|
||||||
|
activation: $activationSnapshot,
|
||||||
|
actor: $actor,
|
||||||
|
auditRecorder: $auditRecorder,
|
||||||
|
workspaceAuditLogger: $workspaceAuditLogger,
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($label.' resumed')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function historyActionFor(string $controlKey): Action
|
||||||
|
{
|
||||||
|
$label = app(OperationalControlCatalog::class)->label($controlKey);
|
||||||
|
|
||||||
|
return Action::make('view_history_'.$this->actionSlug($controlKey))
|
||||||
|
->label('View '.$label.' history')
|
||||||
|
->link()
|
||||||
|
->modalHeading($label.' history')
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelActionLabel('Close')
|
||||||
|
->modalContent(fn () => view('filament.system.pages.ops.partials.operational-control-history', [
|
||||||
|
'events' => $this->recentAuditEventsForControl($controlKey),
|
||||||
|
'label' => $label,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, \Filament\Schemas\Components\Component>
|
||||||
|
*/
|
||||||
|
private function pauseFormSchema(string $controlKey): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Radio::make('scope_type')
|
||||||
|
->label('Scope')
|
||||||
|
->options($this->scopeOptions($controlKey))
|
||||||
|
->default($this->defaultScopeFor($controlKey))
|
||||||
|
->live()
|
||||||
|
->required(),
|
||||||
|
|
||||||
|
Select::make('workspace_id')
|
||||||
|
->label('Workspace')
|
||||||
|
->searchable()
|
||||||
|
->visible(fn (callable $get): bool => $get('scope_type') === 'workspace')
|
||||||
|
->required(fn (callable $get): bool => $get('scope_type') === 'workspace')
|
||||||
|
->live()
|
||||||
|
->getSearchResultsUsing(function (string $search): array {
|
||||||
|
return Workspace::query()
|
||||||
|
->where('name', 'like', "%{$search}%")
|
||||||
|
->orderBy('name')
|
||||||
|
->limit(25)
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->getOptionLabelUsing(function ($value): ?string {
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Workspace::query()->whereKey((int) $value)->value('name');
|
||||||
|
}),
|
||||||
|
|
||||||
|
Textarea::make('reason_text')
|
||||||
|
->label('Reason')
|
||||||
|
->required()
|
||||||
|
->minLength(5)
|
||||||
|
->maxLength(500)
|
||||||
|
->rows(4),
|
||||||
|
|
||||||
|
DateTimePicker::make('expires_at')
|
||||||
|
->label('Expires at')
|
||||||
|
->seconds(false)
|
||||||
|
->nullable(),
|
||||||
|
|
||||||
|
Placeholder::make('scope_preview')
|
||||||
|
->label('Scope impact preview')
|
||||||
|
->content(function (callable $get) use ($controlKey): string {
|
||||||
|
$preview = $this->scopeImpactPreview(
|
||||||
|
$controlKey,
|
||||||
|
(string) ($get('scope_type') ?? 'global'),
|
||||||
|
is_numeric($get('workspace_id')) ? (int) $get('workspace_id') : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (string) $preview['summary'];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, \Filament\Schemas\Components\Component>
|
||||||
|
*/
|
||||||
|
private function resumeFormSchema(string $controlKey): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Radio::make('scope_type')
|
||||||
|
->label('Scope')
|
||||||
|
->options($this->scopeOptions($controlKey))
|
||||||
|
->default($this->defaultScopeFor($controlKey))
|
||||||
|
->live()
|
||||||
|
->required(),
|
||||||
|
|
||||||
|
Select::make('workspace_id')
|
||||||
|
->label('Workspace')
|
||||||
|
->searchable()
|
||||||
|
->visible(fn (callable $get): bool => $get('scope_type') === 'workspace')
|
||||||
|
->required(fn (callable $get): bool => $get('scope_type') === 'workspace')
|
||||||
|
->getSearchResultsUsing(function (string $search): array {
|
||||||
|
return Workspace::query()
|
||||||
|
->where('name', 'like', "%{$search}%")
|
||||||
|
->orderBy('name')
|
||||||
|
->limit(25)
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->getOptionLabelUsing(function ($value): ?string {
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Workspace::query()->whereKey((int) $value)->value('name');
|
||||||
|
}),
|
||||||
|
|
||||||
|
Placeholder::make('scope_preview')
|
||||||
|
->label('Resume impact preview')
|
||||||
|
->content(function (callable $get) use ($controlKey): string {
|
||||||
|
$preview = $this->scopeImpactPreview(
|
||||||
|
$controlKey,
|
||||||
|
(string) ($get('scope_type') ?? 'global'),
|
||||||
|
is_numeric($get('workspace_id')) ? (int) $get('workspace_id') : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (string) $preview['summary'];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function controlsActor(): PlatformUser
|
||||||
|
{
|
||||||
|
$actor = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $actor instanceof PlatformUser) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $actor->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: string, 1: ?Workspace, 2: string, 3: ?CarbonInterface}
|
||||||
|
*/
|
||||||
|
private function normalizePauseInput(string $controlKey, array $data): array
|
||||||
|
{
|
||||||
|
[$scopeType, $workspace] = $this->resolveScopeInput($controlKey, $data);
|
||||||
|
$reasonText = trim((string) ($data['reason_text'] ?? ''));
|
||||||
|
|
||||||
|
if ($reasonText === '') {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'reason_text' => 'A reason is required.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresAt = null;
|
||||||
|
|
||||||
|
if (filled($data['expires_at'] ?? null)) {
|
||||||
|
$expiresAt = Carbon::parse((string) $data['expires_at']);
|
||||||
|
|
||||||
|
if ($expiresAt->lessThanOrEqualTo(now())) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'expires_at' => 'Expiry must be in the future.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$scopeType, $workspace, $reasonText, $expiresAt];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: string, 1: ?Workspace}
|
||||||
|
*/
|
||||||
|
private function normalizeResumeInput(string $controlKey, array $data): array
|
||||||
|
{
|
||||||
|
return $this->resolveScopeInput($controlKey, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: string, 1: ?Workspace}
|
||||||
|
*/
|
||||||
|
private function resolveScopeInput(string $controlKey, array $data): array
|
||||||
|
{
|
||||||
|
$scopeType = (string) ($data['scope_type'] ?? 'global');
|
||||||
|
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'] ?? ['global'];
|
||||||
|
|
||||||
|
if (! in_array($scopeType, $supportedScopes, true)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'scope_type' => 'Invalid scope selected.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scopeType === 'global') {
|
||||||
|
return [$scopeType, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = $data['workspace_id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_numeric($workspaceId)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'workspace_id' => 'A workspace is required for workspace scope.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey((int) $workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'workspace_id' => 'The selected workspace could not be found.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$scopeType, $workspace];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function scopeOptions(string $controlKey): array
|
||||||
|
{
|
||||||
|
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
|
||||||
|
|
||||||
|
return Arr::only([
|
||||||
|
'global' => 'Global',
|
||||||
|
'workspace' => 'One workspace',
|
||||||
|
], $supportedScopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultScopeFor(string $controlKey): string
|
||||||
|
{
|
||||||
|
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
|
||||||
|
|
||||||
|
return $supportedScopes[0] ?? 'global';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function activationScopeQuery(string $controlKey, string $scopeType, ?Workspace $workspace): \Illuminate\Database\Eloquent\Builder
|
||||||
|
{
|
||||||
|
$query = OperationalControlActivation::query()
|
||||||
|
->forControl($controlKey)
|
||||||
|
->where('scope_type', $scopeType);
|
||||||
|
|
||||||
|
if ($scopeType === 'workspace') {
|
||||||
|
$query->where('workspace_id', (int) $workspace?->getKey());
|
||||||
|
} else {
|
||||||
|
$query->whereNull('workspace_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordControlMutation(
|
||||||
|
AuditActionId $auditAction,
|
||||||
|
OperationalControlActivation $activation,
|
||||||
|
PlatformUser $actor,
|
||||||
|
AuditRecorder $auditRecorder,
|
||||||
|
WorkspaceAuditLogger $workspaceAuditLogger,
|
||||||
|
): void {
|
||||||
|
$label = app(OperationalControlCatalog::class)->label((string) $activation->control_key);
|
||||||
|
$summary = sprintf('%s %s', $label, match ($auditAction) {
|
||||||
|
AuditActionId::OperationalControlPaused => 'paused',
|
||||||
|
AuditActionId::OperationalControlUpdated => 'updated',
|
||||||
|
AuditActionId::OperationalControlResumed => 'resumed',
|
||||||
|
default => 'changed',
|
||||||
|
});
|
||||||
|
|
||||||
|
$metadata = array_filter([
|
||||||
|
'control_key' => (string) $activation->control_key,
|
||||||
|
'scope_type' => (string) $activation->scope_type,
|
||||||
|
'workspace_id' => is_numeric($activation->workspace_id) ? (int) $activation->workspace_id : null,
|
||||||
|
'reason_text' => $activation->reason_text,
|
||||||
|
'expires_at' => $activation->expires_at?->toIso8601String(),
|
||||||
|
'actor_id' => (int) $actor->getKey(),
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||||
|
|
||||||
|
if ((string) $activation->scope_type === 'global') {
|
||||||
|
$auditRecorder->record(
|
||||||
|
action: $auditAction,
|
||||||
|
context: ['metadata' => $metadata],
|
||||||
|
actor: AuditActorSnapshot::platform($actor),
|
||||||
|
target: new AuditTargetSnapshot(
|
||||||
|
type: 'operational_control',
|
||||||
|
id: (string) $activation->getKey(),
|
||||||
|
label: $label,
|
||||||
|
),
|
||||||
|
outcome: 'success',
|
||||||
|
summary: $summary,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey((int) $activation->workspace_id)->firstOrFail();
|
||||||
|
|
||||||
|
$workspaceAuditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: $auditAction,
|
||||||
|
context: ['metadata' => $metadata],
|
||||||
|
actor: $actor,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'operational_control',
|
||||||
|
resourceId: (string) $activation->getKey(),
|
||||||
|
targetLabel: $label,
|
||||||
|
summary: $summary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, OperationalControlActivation>
|
||||||
|
*/
|
||||||
|
private function activeActivationsForControl(string $controlKey): Collection
|
||||||
|
{
|
||||||
|
return OperationalControlActivation::query()
|
||||||
|
->forControl($controlKey)
|
||||||
|
->notExpired()
|
||||||
|
->with(['workspace', 'createdBy', 'updatedBy'])
|
||||||
|
->orderByRaw("CASE WHEN scope_type = 'global' THEN 0 ELSE 1 END")
|
||||||
|
->orderBy('workspace_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function activationSummary(OperationalControlActivation $activation): array
|
||||||
|
{
|
||||||
|
$owner = $activation->updatedBy ?? $activation->createdBy;
|
||||||
|
$workspaceName = $activation->workspace?->name;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $activation->getKey(),
|
||||||
|
'scope_type' => (string) $activation->scope_type,
|
||||||
|
'scope_label' => (string) $activation->scope_type === 'global'
|
||||||
|
? 'Global'
|
||||||
|
: sprintf('Workspace: %s', $workspaceName ?? '#'.(int) $activation->workspace_id),
|
||||||
|
'workspace_id' => is_numeric($activation->workspace_id) ? (int) $activation->workspace_id : null,
|
||||||
|
'workspace_name' => $workspaceName,
|
||||||
|
'reason_text' => (string) $activation->reason_text,
|
||||||
|
'expires_at' => $activation->expires_at?->toIso8601String(),
|
||||||
|
'expires_label' => $activation->expires_at?->diffForHumans() ?? 'No expiry',
|
||||||
|
'owner_name' => $owner?->name ?: $owner?->email ?: 'Unknown operator',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, AuditLog>
|
||||||
|
*/
|
||||||
|
private function recentAuditEventsForControl(string $controlKey): Collection
|
||||||
|
{
|
||||||
|
return AuditLog::query()
|
||||||
|
->where('metadata->control_key', $controlKey)
|
||||||
|
->whereIn('action', [
|
||||||
|
AuditActionId::OperationalControlPaused->value,
|
||||||
|
AuditActionId::OperationalControlUpdated->value,
|
||||||
|
AuditActionId::OperationalControlResumed->value,
|
||||||
|
AuditActionId::OperationalControlExecutionBlocked->value,
|
||||||
|
])
|
||||||
|
->latestFirst()
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function actionSlug(string $controlKey): string
|
||||||
|
{
|
||||||
|
return str_replace('.', '_', $controlKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Services\System\AllowedTenantUniverse;
|
use App\Services\System\AllowedTenantUniverse;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\Radio;
|
use Filament\Forms\Components\Radio;
|
||||||
@ -168,12 +169,22 @@ protected function getHeaderActions(): array
|
|||||||
'reason_text' => $data['reason_text'] ?? null,
|
'reason_text' => $data['reason_text'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$run = $runbookService->start(
|
try {
|
||||||
scope: $scope,
|
$run = $runbookService->start(
|
||||||
initiator: $user,
|
scope: $scope,
|
||||||
reason: $reason,
|
initiator: $user,
|
||||||
source: 'system_ui',
|
reason: $reason,
|
||||||
);
|
source: 'system_ui',
|
||||||
|
);
|
||||||
|
} catch (OperationalControlBlockedException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->title($exception->title())
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
throw new \Filament\Support\Exceptions\Halt;
|
||||||
|
}
|
||||||
|
|
||||||
$viewUrl = SystemOperationRunLinks::view($run);
|
$viewUrl = SystemOperationRunLinks::view($run);
|
||||||
|
|
||||||
|
|||||||
@ -19,12 +19,14 @@ class ControlTowerKpis extends StatsOverviewWidget
|
|||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
|
public ?string $window = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<Stat>
|
* @return array<Stat>
|
||||||
*/
|
*/
|
||||||
protected function getStats(): array
|
protected function getStats(): array
|
||||||
{
|
{
|
||||||
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
|
||||||
$start = $window->startAt();
|
$start = $window->startAt();
|
||||||
|
|
||||||
$baseQuery = OperationRun::query()->where('created_at', '>=', $start);
|
$baseQuery = OperationRun::query()->where('created_at', '>=', $start);
|
||||||
|
|||||||
@ -21,12 +21,14 @@ class ControlTowerRecentFailures extends Widget
|
|||||||
|
|
||||||
protected string $view = 'filament.system.widgets.control-tower-recent-failures';
|
protected string $view = 'filament.system.widgets.control-tower-recent-failures';
|
||||||
|
|
||||||
|
public ?string $window = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
protected function getViewData(): array
|
protected function getViewData(): array
|
||||||
{
|
{
|
||||||
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
|
||||||
$start = $window->startAt();
|
$start = $window->startAt();
|
||||||
|
|
||||||
/** @var Collection<int, OperationRun> $runs */
|
/** @var Collection<int, OperationRun> $runs */
|
||||||
|
|||||||
@ -23,12 +23,14 @@ class ControlTowerTopOffenders extends Widget
|
|||||||
|
|
||||||
protected string $view = 'filament.system.widgets.control-tower-top-offenders';
|
protected string $view = 'filament.system.widgets.control-tower-top-offenders';
|
||||||
|
|
||||||
|
public ?string $window = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
protected function getViewData(): array
|
protected function getViewData(): array
|
||||||
{
|
{
|
||||||
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
|
||||||
$start = $window->startAt();
|
$start = $window->startAt();
|
||||||
|
|
||||||
/** @var Collection<int, OperationRun> $grouped */
|
/** @var Collection<int, OperationRun> $grouped */
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\System\Widgets;
|
||||||
|
|
||||||
|
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
||||||
|
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
|
|
||||||
|
class CustomerHealthKpis extends StatsOverviewWidget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected ?string $heading = 'Customer health';
|
||||||
|
|
||||||
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
|
public ?string $window = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Stat>
|
||||||
|
*/
|
||||||
|
protected function getStats(): array
|
||||||
|
{
|
||||||
|
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
|
||||||
|
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
|
||||||
|
$counts = app(WorkspaceHealthSummaryQuery::class)->healthCounts($window);
|
||||||
|
|
||||||
|
return [
|
||||||
|
Stat::make('Healthy', $counts['ok'])
|
||||||
|
->description(sprintf('Operational stability, review-pack readiness, and engagement freshness honor %s.', $windowLabel))
|
||||||
|
->color($counts['ok'] > 0 ? 'success' : 'gray'),
|
||||||
|
Stat::make('Warning', $counts['warn'])
|
||||||
|
->description('Onboarding readiness, provider health, and governance pressure stay point-in-time.')
|
||||||
|
->color($counts['warn'] > 0 ? 'warning' : 'gray'),
|
||||||
|
Stat::make('Critical', $counts['critical'])
|
||||||
|
->description('Overall workspace health is derived from existing system truth only.')
|
||||||
|
->color($counts['critical'] > 0 ? 'danger' : 'gray'),
|
||||||
|
Stat::make('Unknown', $counts['unknown'])
|
||||||
|
->description('Missing or stale inputs stay explicit instead of silently reading healthy.')
|
||||||
|
->color('gray'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\System\Widgets;
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
||||||
|
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||||
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
|
class CustomerHealthTopWorkspaces extends Widget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
|
protected string $view = 'filament.system.widgets.customer-health-top-workspaces';
|
||||||
|
|
||||||
|
public ?string $window = null;
|
||||||
|
|
||||||
|
public static function canView(): bool
|
||||||
|
{
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $user instanceof PlatformUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! static::canOpenRuns($user)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
||||||
|
|
||||||
|
return app(WorkspaceHealthSummaryQuery::class)
|
||||||
|
->attentionNeeded($window, 10)
|
||||||
|
->contains(fn (array $summary): bool => static::canAccessNextLink($summary['next_link'], $user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
|
||||||
|
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'windowLabel' => $windowLabel,
|
||||||
|
'rows' => app(WorkspaceHealthSummaryQuery::class)
|
||||||
|
->attentionNeeded($window, 10)
|
||||||
|
->filter(fn (array $summary): bool => $user instanceof PlatformUser && static::canAccessNextLink($summary['next_link'], $user))
|
||||||
|
->map(fn (array $summary): array => $this->presentSummary($summary)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{label: string, url: string} $nextLink
|
||||||
|
*/
|
||||||
|
private static function canAccessNextLink(array $nextLink, PlatformUser $user): bool
|
||||||
|
{
|
||||||
|
if ($user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::canOpenRuns($user)
|
||||||
|
&& $nextLink['url'] === SystemOperationRunLinks::index();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function canOpenRuns(PlatformUser $user): bool
|
||||||
|
{
|
||||||
|
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
||||||
|
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* workspace_name: string,
|
||||||
|
* overall_level: string,
|
||||||
|
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
||||||
|
* dominant_dimension_keys: list<string>,
|
||||||
|
* non_ok_dimension_count: int,
|
||||||
|
* next_link: array{label: string, url: string}
|
||||||
|
* } $summary
|
||||||
|
* @return array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* workspace_label: string,
|
||||||
|
* overall: array{label: string, color: string, icon: ?string},
|
||||||
|
* dominant_copy: string,
|
||||||
|
* dominant_dimensions: list<array{label: string, color: string, icon: ?string}>,
|
||||||
|
* next_link: array{label: string, url: string}
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function presentSummary(array $summary): array
|
||||||
|
{
|
||||||
|
$dominantDimensions = collect($summary['dominant_dimension_keys'])
|
||||||
|
->take(2)
|
||||||
|
->map(function (string $dimensionKey) use ($summary): array {
|
||||||
|
$dimension = $summary['dimensions'][$dimensionKey];
|
||||||
|
$badge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $dimension['level']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => $dimension['label'],
|
||||||
|
'color' => $badge->color,
|
||||||
|
'icon' => $badge->icon,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$overallBadge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $summary['overall_level']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workspace_id' => $summary['workspace_id'],
|
||||||
|
'workspace_label' => $summary['workspace_name'],
|
||||||
|
'overall' => [
|
||||||
|
'label' => $overallBadge->label,
|
||||||
|
'color' => $overallBadge->color,
|
||||||
|
'icon' => $overallBadge->icon,
|
||||||
|
],
|
||||||
|
'dominant_copy' => implode(', ', array_column($dominantDimensions, 'label')),
|
||||||
|
'dominant_dimensions' => $dominantDimensions,
|
||||||
|
'next_link' => $summary['next_link'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\System\Widgets;
|
||||||
|
|
||||||
|
use App\Support\ProductTelemetry\ProductTelemetrySummaryQuery;
|
||||||
|
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
|
|
||||||
|
class ProductTelemetryKpis extends StatsOverviewWidget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected ?string $heading = 'Product telemetry';
|
||||||
|
|
||||||
|
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';
|
||||||
|
$summary = app(ProductTelemetrySummaryQuery::class)->summarize($window->startAt(), now());
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
Stat::make('Active workspaces', $summary['active_workspaces'])
|
||||||
|
->description($summary['total_events'] > 0
|
||||||
|
? sprintf('%d events in %s', $summary['total_events'], $windowLabel)
|
||||||
|
: sprintf('No telemetry recorded in %s.', $windowLabel))
|
||||||
|
->color($summary['active_workspaces'] > 0 ? 'primary' : 'gray'),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($summary['families'] as $family) {
|
||||||
|
$stats[] = Stat::make($family['label'], $family['count'])
|
||||||
|
->description($windowLabel)
|
||||||
|
->color($family['count'] > 0 ? 'primary' : 'gray');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Tenant;
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
|
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||||
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -18,6 +20,7 @@
|
|||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
class TenantReviewPackCard extends Widget
|
class TenantReviewPackCard extends Widget
|
||||||
@ -66,6 +69,18 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
|
|||||||
/** @var ReviewPackService $service */
|
/** @var ReviewPackService $service */
|
||||||
$service = app(ReviewPackService::class);
|
$service = app(ReviewPackService::class);
|
||||||
|
|
||||||
|
$decision = $service->reviewPackGenerationDecisionForTenant($tenant);
|
||||||
|
|
||||||
|
if ((bool) ($decision['is_blocked'] ?? false)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Review pack generation unavailable')
|
||||||
|
->body((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'))
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$activeRun = $service->checkActiveRun($tenant)
|
$activeRun = $service->checkActiveRun($tenant)
|
||||||
? OperationRun::query()
|
? OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
@ -90,10 +105,20 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reviewPack = $service->generate($tenant, $user, [
|
try {
|
||||||
'include_pii' => $includePii,
|
$reviewPack = $service->generate($tenant, $user, [
|
||||||
'include_operations' => $includeOperations,
|
'include_pii' => $includePii,
|
||||||
]);
|
'include_operations' => $includeOperations,
|
||||||
|
]);
|
||||||
|
} catch (WorkspaceEntitlementBlockedException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Review pack generation unavailable')
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$runUrl = $reviewPack->operationRun
|
$runUrl = $reviewPack->operationRun
|
||||||
? OperationRunLinks::tenantlessView($reviewPack->operationRun)
|
? OperationRunLinks::tenantlessView($reviewPack->operationRun)
|
||||||
@ -130,6 +155,14 @@ protected function getViewData(): array
|
|||||||
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
|
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
|
||||||
$canView = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
|
$canView = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
|
||||||
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
||||||
|
$service = app(ReviewPackService::class);
|
||||||
|
$generationEntitlement = $canManage
|
||||||
|
? $service->reviewPackGenerationDecisionForTenant($tenant)
|
||||||
|
: null;
|
||||||
|
$generationBlocked = (bool) ($generationEntitlement['is_blocked'] ?? false);
|
||||||
|
$generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null)
|
||||||
|
? $generationEntitlement['block_reason']
|
||||||
|
: null;
|
||||||
|
|
||||||
$latestPack = ReviewPack::query()
|
$latestPack = ReviewPack::query()
|
||||||
->with(['tenantReview', 'operationRun'])
|
->with(['tenantReview', 'operationRun'])
|
||||||
@ -146,6 +179,9 @@ protected function getViewData(): array
|
|||||||
'pollingInterval' => null,
|
'pollingInterval' => null,
|
||||||
'canView' => $canView,
|
'canView' => $canView,
|
||||||
'canManage' => $canManage,
|
'canManage' => $canManage,
|
||||||
|
'generationBlocked' => $generationBlocked,
|
||||||
|
'generationBlockReason' => $generationBlockReason,
|
||||||
|
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
|
||||||
'downloadUrl' => null,
|
'downloadUrl' => null,
|
||||||
'failedReason' => null,
|
'failedReason' => null,
|
||||||
'reviewUrl' => null,
|
'reviewUrl' => null,
|
||||||
@ -194,6 +230,9 @@ protected function getViewData(): array
|
|||||||
'pollingInterval' => self::resolvePollingInterval($latestPack),
|
'pollingInterval' => self::resolvePollingInterval($latestPack),
|
||||||
'canView' => $canView,
|
'canView' => $canView,
|
||||||
'canManage' => $canManage,
|
'canManage' => $canManage,
|
||||||
|
'generationBlocked' => $generationBlocked,
|
||||||
|
'generationBlockReason' => $generationBlockReason,
|
||||||
|
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
|
||||||
'downloadUrl' => $downloadUrl,
|
'downloadUrl' => $downloadUrl,
|
||||||
'failedReason' => $failedReason,
|
'failedReason' => $failedReason,
|
||||||
'failedReasonDetail' => $failedReasonDetail,
|
'failedReasonDetail' => $failedReasonDetail,
|
||||||
@ -224,6 +263,9 @@ private function emptyState(): array
|
|||||||
'pollingInterval' => null,
|
'pollingInterval' => null,
|
||||||
'canView' => false,
|
'canView' => false,
|
||||||
'canManage' => false,
|
'canManage' => false,
|
||||||
|
'generationBlocked' => false,
|
||||||
|
'generationBlockReason' => null,
|
||||||
|
'customerWorkspaceUrl' => null,
|
||||||
'downloadUrl' => null,
|
'downloadUrl' => null,
|
||||||
'failedReason' => null,
|
'failedReason' => null,
|
||||||
'failedReasonDetail' => null,
|
'failedReasonDetail' => null,
|
||||||
|
|||||||
@ -4,7 +4,12 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@ -15,6 +20,21 @@ class ReviewPackDownloadController extends Controller
|
|||||||
{
|
{
|
||||||
public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResponse
|
public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResponse
|
||||||
{
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$tenant = $reviewPack->tenant;
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
if ($reviewPack->status !== ReviewPackStatus::Ready->value) {
|
if ($reviewPack->status !== ReviewPackStatus::Ready->value) {
|
||||||
throw new NotFoundHttpException;
|
throw new NotFoundHttpException;
|
||||||
}
|
}
|
||||||
@ -29,7 +49,26 @@ public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResp
|
|||||||
throw new NotFoundHttpException;
|
throw new NotFoundHttpException;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $reviewPack->tenant;
|
app(WorkspaceAuditLogger::class)->log(
|
||||||
|
workspace: $tenant->workspace,
|
||||||
|
action: AuditActionId::ReviewPackDownloaded,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'review_pack_id' => (int) $reviewPack->getKey(),
|
||||||
|
'tenant_review_id' => $reviewPack->tenant_review_id !== null
|
||||||
|
? (int) $reviewPack->tenant_review_id
|
||||||
|
: null,
|
||||||
|
'source_surface' => (string) $request->query('source_surface', 'review_pack'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actor: $user,
|
||||||
|
resourceType: 'review_pack',
|
||||||
|
resourceId: (string) $reviewPack->getKey(),
|
||||||
|
targetLabel: sprintf('Review pack #%d', (int) $reviewPack->getKey()),
|
||||||
|
tenant: $tenant,
|
||||||
|
operationRunId: $reviewPack->operation_run_id,
|
||||||
|
);
|
||||||
|
|
||||||
$filename = sprintf(
|
$filename = sprintf(
|
||||||
'review-pack-%s-%s.zip',
|
'review-pack-%s-%s.zip',
|
||||||
$tenant?->external_id ?? 'unknown',
|
$tenant?->external_id ?? 'unknown',
|
||||||
|
|||||||
73
apps/platform/app/Models/OperationalControlActivation.php
Normal file
73
apps/platform/app/Models/OperationalControlActivation.php
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\OperationalControlActivationFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class OperationalControlActivation extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<OperationalControlActivationFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function newFactory(): OperationalControlActivationFactory
|
||||||
|
{
|
||||||
|
return OperationalControlActivationFactory::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createdBy(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PlatformUser::class, 'created_by_platform_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedBy(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PlatformUser::class, 'updated_by_platform_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForControl(Builder $query, string $controlKey): Builder
|
||||||
|
{
|
||||||
|
return $query->where('control_key', trim($controlKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForGlobalScope(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('scope_type', 'global');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForWorkspaceScope(Builder $query, int|Workspace $workspace): Builder
|
||||||
|
{
|
||||||
|
$workspaceId = $workspace instanceof Workspace
|
||||||
|
? (int) $workspace->getKey()
|
||||||
|
: (int) $workspace;
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->where('scope_type', 'workspace')
|
||||||
|
->where('workspace_id', $workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeNotExpired(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where(function (Builder $query): void {
|
||||||
|
$query
|
||||||
|
->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
55
apps/platform/app/Models/ProductUsageEvent.php
Normal file
55
apps/platform/app/Models/ProductUsageEvent.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?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 ProductUsageEvent extends Model
|
||||||
|
{
|
||||||
|
use DerivesWorkspaceIdFromTenant;
|
||||||
|
|
||||||
|
/** @use HasFactory<\Database\Factories\ProductUsageEventFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'metadata' => 'array',
|
||||||
|
'occurred_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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)->withTrashed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<User, $this>
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
apps/platform/app/Models/SupportRequest.php
Normal file
121
apps/platform/app/Models/SupportRequest.php
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class SupportRequest extends Model
|
||||||
|
{
|
||||||
|
use DerivesWorkspaceIdFromTenant;
|
||||||
|
|
||||||
|
/** @use HasFactory<\Database\Factories\SupportRequestFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const string PRIMARY_CONTEXT_TENANT = 'tenant';
|
||||||
|
|
||||||
|
public const string PRIMARY_CONTEXT_OPERATION_RUN = 'operation_run';
|
||||||
|
|
||||||
|
public const string ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED = 'diagnostic_snapshot_attached';
|
||||||
|
|
||||||
|
public const string ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY = 'canonical_context_only';
|
||||||
|
|
||||||
|
public const string SEVERITY_LOW = 'low';
|
||||||
|
|
||||||
|
public const string SEVERITY_NORMAL = 'normal';
|
||||||
|
|
||||||
|
public const string SEVERITY_HIGH = 'high';
|
||||||
|
|
||||||
|
public const string SEVERITY_BLOCKING = 'blocking';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'context_envelope' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function severityOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::SEVERITY_LOW => 'Low',
|
||||||
|
self::SEVERITY_NORMAL => 'Normal',
|
||||||
|
self::SEVERITY_HIGH => 'High',
|
||||||
|
self::SEVERITY_BLOCKING => 'Blocking',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function severityValues(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::severityOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function primaryContextTypes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::PRIMARY_CONTEXT_TENANT,
|
||||||
|
self::PRIMARY_CONTEXT_OPERATION_RUN,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function attachmentModes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
|
||||||
|
self::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Workspace, $this>
|
||||||
|
*/
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Tenant, $this>
|
||||||
|
*/
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<OperationRun, $this>
|
||||||
|
*/
|
||||||
|
public function operationRun(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(OperationRun::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<User, $this>
|
||||||
|
*/
|
||||||
|
public function initiator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||||
use App\Filament\Pages\NoAccess;
|
use App\Filament\Pages\NoAccess;
|
||||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||||
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||||
use App\Filament\Pages\TenantRequiredPermissions;
|
use App\Filament\Pages\TenantRequiredPermissions;
|
||||||
use App\Filament\Pages\WorkspaceOverview;
|
use App\Filament\Pages\WorkspaceOverview;
|
||||||
@ -183,6 +184,7 @@ public function panel(Panel $panel): Panel
|
|||||||
FindingsIntakeQueue::class,
|
FindingsIntakeQueue::class,
|
||||||
MyFindingsInbox::class,
|
MyFindingsInbox::class,
|
||||||
FindingExceptionsQueue::class,
|
FindingExceptionsQueue::class,
|
||||||
|
CustomerReviewWorkspace::class,
|
||||||
ReviewRegister::class,
|
ReviewRegister::class,
|
||||||
])
|
])
|
||||||
->widgets([
|
->widgets([
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Services\Audit;
|
namespace App\Services\Audit;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
@ -12,6 +15,7 @@
|
|||||||
use App\Support\Audit\AuditActorType;
|
use App\Support\Audit\AuditActorType;
|
||||||
use App\Support\Audit\AuditTargetSnapshot;
|
use App\Support\Audit\AuditTargetSnapshot;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
class WorkspaceAuditLogger
|
class WorkspaceAuditLogger
|
||||||
{
|
{
|
||||||
@ -23,7 +27,7 @@ public function log(
|
|||||||
Workspace $workspace,
|
Workspace $workspace,
|
||||||
string|AuditActionId $action,
|
string|AuditActionId $action,
|
||||||
array $context = [],
|
array $context = [],
|
||||||
?User $actor = null,
|
User|PlatformUser|null $actor = null,
|
||||||
string $status = 'success',
|
string $status = 'success',
|
||||||
?string $resourceType = null,
|
?string $resourceType = null,
|
||||||
?string $resourceId = null,
|
?string $resourceId = null,
|
||||||
@ -36,14 +40,16 @@ public function log(
|
|||||||
?int $operationRunId = null,
|
?int $operationRunId = null,
|
||||||
?Tenant $tenant = null,
|
?Tenant $tenant = null,
|
||||||
): \App\Models\AuditLog {
|
): \App\Models\AuditLog {
|
||||||
$resolvedActor = $actor instanceof User
|
$resolvedActor = match (true) {
|
||||||
? AuditActorSnapshot::human($actor)
|
$actor instanceof User => AuditActorSnapshot::human($actor),
|
||||||
: AuditActorSnapshot::fromLegacy(
|
$actor instanceof PlatformUser => AuditActorSnapshot::platform($actor),
|
||||||
|
default => AuditActorSnapshot::fromLegacy(
|
||||||
type: $actorType ?? AuditActorType::infer($action instanceof AuditActionId ? $action->value : $action, $actorId, $actorEmail, $actorName, $context),
|
type: $actorType ?? AuditActorType::infer($action instanceof AuditActionId ? $action->value : $action, $actorId, $actorEmail, $actorName, $context),
|
||||||
id: $actorId,
|
id: $actorId,
|
||||||
email: $actorEmail,
|
email: $actorEmail,
|
||||||
label: $actorName,
|
label: $actorName,
|
||||||
);
|
),
|
||||||
|
};
|
||||||
|
|
||||||
return $this->auditRecorder->record(
|
return $this->auditRecorder->record(
|
||||||
action: $action,
|
action: $action,
|
||||||
@ -70,7 +76,7 @@ public function logTenantLifecycleAction(
|
|||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
string|AuditActionId $action,
|
string|AuditActionId $action,
|
||||||
array $context = [],
|
array $context = [],
|
||||||
?User $actor = null,
|
User|PlatformUser|null $actor = null,
|
||||||
string $status = 'success',
|
string $status = 'success',
|
||||||
?string $summary = null,
|
?string $summary = null,
|
||||||
): \App\Models\AuditLog {
|
): \App\Models\AuditLog {
|
||||||
@ -87,4 +93,84 @@ public function logTenantLifecycleAction(
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $bundle
|
||||||
|
*/
|
||||||
|
public function logSupportDiagnosticsOpened(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $contextType,
|
||||||
|
array $bundle,
|
||||||
|
User|PlatformUser|null $actor = null,
|
||||||
|
?OperationRun $operationRun = null,
|
||||||
|
): \App\Models\AuditLog {
|
||||||
|
$sectionCount = is_array($bundle['sections'] ?? null) ? count($bundle['sections']) : 0;
|
||||||
|
$referenceCount = collect($bundle['sections'] ?? [])
|
||||||
|
->sum(static fn (mixed $section): int => is_array($section) && is_array($section['references'] ?? null)
|
||||||
|
? count($section['references'])
|
||||||
|
: 0);
|
||||||
|
|
||||||
|
return $this->log(
|
||||||
|
workspace: $tenant->workspace,
|
||||||
|
action: AuditActionId::SupportDiagnosticsOpened,
|
||||||
|
context: [
|
||||||
|
'context_type' => $contextType,
|
||||||
|
'redaction_mode' => 'default_redacted',
|
||||||
|
'section_count' => $sectionCount,
|
||||||
|
'reference_count' => $referenceCount,
|
||||||
|
'primary_context_id' => $operationRun instanceof OperationRun
|
||||||
|
? (string) $operationRun->getKey()
|
||||||
|
: (string) $tenant->getKey(),
|
||||||
|
],
|
||||||
|
actor: $actor,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'support_diagnostic_bundle',
|
||||||
|
resourceId: $operationRun instanceof OperationRun
|
||||||
|
? 'operation_run:'.$operationRun->getKey()
|
||||||
|
: 'tenant:'.$tenant->getKey(),
|
||||||
|
targetLabel: $operationRun instanceof OperationRun
|
||||||
|
? 'Support diagnostics for operation #'.$operationRun->getKey()
|
||||||
|
: 'Support diagnostics for '.$tenant->name,
|
||||||
|
summary: $operationRun instanceof OperationRun
|
||||||
|
? 'Support diagnostics opened for operation #'.$operationRun->getKey()
|
||||||
|
: 'Support diagnostics opened for '.$tenant->name,
|
||||||
|
operationRunId: $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,8 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_MANAGE,
|
Capabilities::TENANT_MANAGE,
|
||||||
Capabilities::TENANT_DELETE,
|
Capabilities::TENANT_DELETE,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
|
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,
|
||||||
@ -63,6 +65,8 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_VIEW,
|
Capabilities::TENANT_VIEW,
|
||||||
Capabilities::TENANT_MANAGE,
|
Capabilities::TENANT_MANAGE,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
|
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,
|
||||||
@ -103,6 +107,8 @@ class RoleCapabilityMap
|
|||||||
TenantRole::Operator->value => [
|
TenantRole::Operator->value => [
|
||||||
Capabilities::TENANT_VIEW,
|
Capabilities::TENANT_VIEW,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
|
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||||
|
Capabilities::SUPPORT_REQUESTS_CREATE,
|
||||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
Capabilities::TENANT_FINDINGS_VIEW,
|
Capabilities::TENANT_FINDINGS_VIEW,
|
||||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||||
|
|||||||
@ -0,0 +1,327 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Entitlements;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceSetting;
|
||||||
|
use App\Services\Settings\SettingsResolver;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
final class WorkspaceEntitlementResolver
|
||||||
|
{
|
||||||
|
public const SETTING_DOMAIN = 'entitlements';
|
||||||
|
|
||||||
|
public const SETTING_PLAN_PROFILE = 'plan_profile';
|
||||||
|
|
||||||
|
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE = 'managed_tenant_limit_override_value';
|
||||||
|
|
||||||
|
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON = 'managed_tenant_limit_override_reason';
|
||||||
|
|
||||||
|
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE = 'review_pack_generation_override_value';
|
||||||
|
|
||||||
|
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON = 'review_pack_generation_override_reason';
|
||||||
|
|
||||||
|
public const KEY_MANAGED_TENANT_ACTIVATION_LIMIT = 'managed_tenant_activation_limit';
|
||||||
|
|
||||||
|
public const KEY_REVIEW_PACK_GENERATION_ENABLED = 'review_pack_generation_enabled';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private SettingsResolver $settingsResolver,
|
||||||
|
private WorkspacePlanProfileCatalog $planProfileCatalog,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* plan_profile: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
|
||||||
|
* decisions: array<string, array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* plan_profile_id: string,
|
||||||
|
* plan_profile_label: string,
|
||||||
|
* plan_profile_description: string,
|
||||||
|
* key: string,
|
||||||
|
* effective_value: int|bool,
|
||||||
|
* source: 'plan_profile_default'|'workspace_override',
|
||||||
|
* rationale: string|null,
|
||||||
|
* current_usage: int|null,
|
||||||
|
* remaining_capacity: int|null,
|
||||||
|
* is_blocked: bool,
|
||||||
|
* block_reason: string|null,
|
||||||
|
* last_changed_at: CarbonInterface|null,
|
||||||
|
* last_changed_by: string|null
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function summary(Workspace $workspace): array
|
||||||
|
{
|
||||||
|
$planProfile = $this->resolvePlanProfile($workspace);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'plan_profile' => $planProfile,
|
||||||
|
'decisions' => [
|
||||||
|
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolve($workspace, self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, $planProfile),
|
||||||
|
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolve($workspace, self::KEY_REVIEW_PACK_GENERATION_ENABLED, $planProfile),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
||||||
|
*/
|
||||||
|
public function resolvePlanProfile(Workspace $workspace): array
|
||||||
|
{
|
||||||
|
$planProfileId = $this->settingsResolver->resolveValue(
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: self::SETTING_DOMAIN,
|
||||||
|
key: self::SETTING_PLAN_PROFILE,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->planProfileCatalog->resolve(is_string($planProfileId) ? $planProfileId : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null $planProfile
|
||||||
|
* @return array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* plan_profile_id: string,
|
||||||
|
* plan_profile_label: string,
|
||||||
|
* plan_profile_description: string,
|
||||||
|
* key: string,
|
||||||
|
* effective_value: int|bool,
|
||||||
|
* source: 'plan_profile_default'|'workspace_override',
|
||||||
|
* rationale: string|null,
|
||||||
|
* current_usage: int|null,
|
||||||
|
* remaining_capacity: int|null,
|
||||||
|
* is_blocked: bool,
|
||||||
|
* block_reason: string|null,
|
||||||
|
* last_changed_at: CarbonInterface|null,
|
||||||
|
* last_changed_by: string|null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function resolve(Workspace $workspace, string $key, ?array $planProfile = null): array
|
||||||
|
{
|
||||||
|
$planProfile ??= $this->resolvePlanProfile($workspace);
|
||||||
|
$lastChanged = $this->lastChangedMetadata($workspace);
|
||||||
|
|
||||||
|
return match ($key) {
|
||||||
|
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolveManagedTenantActivationLimitDecision($workspace, $planProfile, $lastChanged),
|
||||||
|
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolveReviewPackGenerationDecision($workspace, $planProfile, $lastChanged),
|
||||||
|
default => throw new \InvalidArgumentException(sprintf('Unknown workspace entitlement key: %s', $key)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
||||||
|
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
|
||||||
|
* @return array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* plan_profile_id: string,
|
||||||
|
* plan_profile_label: string,
|
||||||
|
* plan_profile_description: string,
|
||||||
|
* key: string,
|
||||||
|
* effective_value: int,
|
||||||
|
* source: 'plan_profile_default'|'workspace_override',
|
||||||
|
* rationale: string|null,
|
||||||
|
* current_usage: int,
|
||||||
|
* remaining_capacity: int,
|
||||||
|
* is_blocked: bool,
|
||||||
|
* block_reason: string|null,
|
||||||
|
* last_changed_at: CarbonInterface|null,
|
||||||
|
* last_changed_by: string|null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function resolveManagedTenantActivationLimitDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
|
||||||
|
{
|
||||||
|
$overrideValue = $this->settingsResolver->resolveDetailed(
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: self::SETTING_DOMAIN,
|
||||||
|
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
|
||||||
|
);
|
||||||
|
|
||||||
|
$overrideReason = $this->settingsResolver->resolveValue(
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: self::SETTING_DOMAIN,
|
||||||
|
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
|
||||||
|
);
|
||||||
|
|
||||||
|
$effectiveValue = is_int($overrideValue['value'])
|
||||||
|
? $overrideValue['value']
|
||||||
|
: (int) $planProfile['managed_tenant_limit_default'];
|
||||||
|
|
||||||
|
$source = $overrideValue['source'] === 'workspace_override'
|
||||||
|
? 'workspace_override'
|
||||||
|
: 'plan_profile_default';
|
||||||
|
|
||||||
|
$currentUsage = Tenant::activeQuery()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$remainingCapacity = $effectiveValue - $currentUsage;
|
||||||
|
$isBlocked = $currentUsage >= $effectiveValue;
|
||||||
|
$rationale = $source === 'workspace_override'
|
||||||
|
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
|
||||||
|
: (string) $planProfile['description'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'plan_profile_id' => (string) $planProfile['id'],
|
||||||
|
'plan_profile_label' => (string) $planProfile['label'],
|
||||||
|
'plan_profile_description' => (string) $planProfile['description'],
|
||||||
|
'key' => self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
||||||
|
'effective_value' => $effectiveValue,
|
||||||
|
'source' => $source,
|
||||||
|
'rationale' => $rationale,
|
||||||
|
'current_usage' => $currentUsage,
|
||||||
|
'remaining_capacity' => $remainingCapacity,
|
||||||
|
'is_blocked' => $isBlocked,
|
||||||
|
'block_reason' => $isBlocked
|
||||||
|
? $this->managedTenantLimitBlockReason($currentUsage, $effectiveValue, $source, $planProfile, $rationale)
|
||||||
|
: null,
|
||||||
|
'last_changed_at' => $lastChanged['last_changed_at'],
|
||||||
|
'last_changed_by' => $lastChanged['last_changed_by'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
||||||
|
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
|
||||||
|
* @return array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* plan_profile_id: string,
|
||||||
|
* plan_profile_label: string,
|
||||||
|
* plan_profile_description: string,
|
||||||
|
* key: string,
|
||||||
|
* effective_value: bool,
|
||||||
|
* source: 'plan_profile_default'|'workspace_override',
|
||||||
|
* rationale: string|null,
|
||||||
|
* current_usage: null,
|
||||||
|
* remaining_capacity: null,
|
||||||
|
* is_blocked: bool,
|
||||||
|
* block_reason: string|null,
|
||||||
|
* last_changed_at: CarbonInterface|null,
|
||||||
|
* last_changed_by: string|null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function resolveReviewPackGenerationDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
|
||||||
|
{
|
||||||
|
$overrideValue = $this->settingsResolver->resolveDetailed(
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: self::SETTING_DOMAIN,
|
||||||
|
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
|
||||||
|
);
|
||||||
|
|
||||||
|
$overrideReason = $this->settingsResolver->resolveValue(
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: self::SETTING_DOMAIN,
|
||||||
|
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
|
||||||
|
);
|
||||||
|
|
||||||
|
$effectiveValue = is_bool($overrideValue['value'])
|
||||||
|
? $overrideValue['value']
|
||||||
|
: (bool) $planProfile['review_pack_generation_default'];
|
||||||
|
|
||||||
|
$source = $overrideValue['source'] === 'workspace_override'
|
||||||
|
? 'workspace_override'
|
||||||
|
: 'plan_profile_default';
|
||||||
|
|
||||||
|
$rationale = $source === 'workspace_override'
|
||||||
|
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
|
||||||
|
: (string) $planProfile['description'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'plan_profile_id' => (string) $planProfile['id'],
|
||||||
|
'plan_profile_label' => (string) $planProfile['label'],
|
||||||
|
'plan_profile_description' => (string) $planProfile['description'],
|
||||||
|
'key' => self::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
||||||
|
'effective_value' => $effectiveValue,
|
||||||
|
'source' => $source,
|
||||||
|
'rationale' => $rationale,
|
||||||
|
'current_usage' => null,
|
||||||
|
'remaining_capacity' => null,
|
||||||
|
'is_blocked' => ! $effectiveValue,
|
||||||
|
'block_reason' => $effectiveValue
|
||||||
|
? null
|
||||||
|
: $this->reviewPackGenerationBlockReason($source, $planProfile, $rationale),
|
||||||
|
'last_changed_at' => $lastChanged['last_changed_at'],
|
||||||
|
'last_changed_by' => $lastChanged['last_changed_by'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
|
||||||
|
*/
|
||||||
|
private function lastChangedMetadata(Workspace $workspace): array
|
||||||
|
{
|
||||||
|
$record = WorkspaceSetting::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('domain', self::SETTING_DOMAIN)
|
||||||
|
->whereIn('key', [
|
||||||
|
self::SETTING_PLAN_PROFILE,
|
||||||
|
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
|
||||||
|
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
|
||||||
|
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
|
||||||
|
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
|
||||||
|
])
|
||||||
|
->whereNotNull('updated_by_user_id')
|
||||||
|
->with('updatedByUser:id,name')
|
||||||
|
->latest('updated_at')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $record instanceof WorkspaceSetting) {
|
||||||
|
return [
|
||||||
|
'last_changed_at' => null,
|
||||||
|
'last_changed_by' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'last_changed_at' => $record->updated_at,
|
||||||
|
'last_changed_by' => $record->updatedByUser?->name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
||||||
|
*/
|
||||||
|
private function managedTenantLimitBlockReason(int $currentUsage, int $effectiveValue, string $source, array $planProfile, ?string $rationale): string
|
||||||
|
{
|
||||||
|
$prefix = $source === 'workspace_override'
|
||||||
|
? 'This workspace override currently allows'
|
||||||
|
: sprintf('The %s plan profile currently allows', $planProfile['label']);
|
||||||
|
|
||||||
|
$message = sprintf(
|
||||||
|
'%s %d active managed tenant%s, and this workspace already has %d active managed tenant%s.',
|
||||||
|
$prefix,
|
||||||
|
$effectiveValue,
|
||||||
|
$effectiveValue === 1 ? '' : 's',
|
||||||
|
$currentUsage,
|
||||||
|
$currentUsage === 1 ? '' : 's',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($source === 'workspace_override' && $rationale !== null) {
|
||||||
|
$message .= ' Reason: '.$rationale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
||||||
|
*/
|
||||||
|
private function reviewPackGenerationBlockReason(string $source, array $planProfile, ?string $rationale): string
|
||||||
|
{
|
||||||
|
$message = $source === 'workspace_override'
|
||||||
|
? 'Review pack generation is disabled by workspace override.'
|
||||||
|
: sprintf('Review pack generation is disabled by the %s plan profile.', $planProfile['label']);
|
||||||
|
|
||||||
|
if ($source === 'workspace_override' && $rationale !== null) {
|
||||||
|
$message .= ' Reason: '.$rationale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Entitlements;
|
||||||
|
|
||||||
|
final class WorkspacePlanProfileCatalog
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
|
||||||
|
*/
|
||||||
|
private const PROFILES = [
|
||||||
|
'starter' => [
|
||||||
|
'id' => 'starter',
|
||||||
|
'label' => 'Starter',
|
||||||
|
'description' => 'Minimal allowance for early workspace access and low-volume operations.',
|
||||||
|
'managed_tenant_limit_default' => 1,
|
||||||
|
'review_pack_generation_default' => false,
|
||||||
|
'is_default' => false,
|
||||||
|
],
|
||||||
|
'standard' => [
|
||||||
|
'id' => 'standard',
|
||||||
|
'label' => 'Standard',
|
||||||
|
'description' => 'Balanced defaults for most managed workspaces.',
|
||||||
|
'managed_tenant_limit_default' => 25,
|
||||||
|
'review_pack_generation_default' => true,
|
||||||
|
'is_default' => true,
|
||||||
|
],
|
||||||
|
'scale' => [
|
||||||
|
'id' => 'scale',
|
||||||
|
'label' => 'Scale',
|
||||||
|
'description' => 'Higher managed-tenant capacity for larger workspace portfolios.',
|
||||||
|
'managed_tenant_limit_default' => 100,
|
||||||
|
'review_pack_generation_default' => true,
|
||||||
|
'is_default' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return array_values(self::PROFILES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
||||||
|
*/
|
||||||
|
public function default(): array
|
||||||
|
{
|
||||||
|
return self::PROFILES[self::defaultProfileId()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null
|
||||||
|
*/
|
||||||
|
public function find(?string $id): ?array
|
||||||
|
{
|
||||||
|
if ($id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::PROFILES[$id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
||||||
|
*/
|
||||||
|
public function resolve(?string $id): array
|
||||||
|
{
|
||||||
|
return $this->find($id) ?? $this->default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function optionLabels(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (array $profile): string => $profile['label'],
|
||||||
|
self::PROFILES,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function profileIds(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::PROFILES);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function defaultProfileId(): string
|
||||||
|
{
|
||||||
|
foreach (self::PROFILES as $id => $profile) {
|
||||||
|
if ($profile['is_default']) {
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException('Workspace plan profile catalog is missing a default profile.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,8 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||||
|
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
|
||||||
|
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@ -18,6 +20,7 @@ public function __construct(
|
|||||||
private readonly GraphClientInterface $graphClient,
|
private readonly GraphClientInterface $graphClient,
|
||||||
private readonly HighPrivilegeRoleCatalog $catalog,
|
private readonly HighPrivilegeRoleCatalog $catalog,
|
||||||
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
||||||
|
private readonly ProductTelemetryRecorder $productTelemetryRecorder,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -57,6 +60,8 @@ public function generate(Tenant $tenant, ?OperationRun $operationRun = null): En
|
|||||||
'previous_fingerprint' => $latestReport?->fingerprint,
|
'previous_fingerprint' => $latestReport?->fingerprint,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->recordStoredReportTelemetry($report, $operationRun);
|
||||||
|
|
||||||
return new EntraAdminRolesReportResult(
|
return new EntraAdminRolesReportResult(
|
||||||
created: true,
|
created: true,
|
||||||
storedReportId: (int) $report->getKey(),
|
storedReportId: (int) $report->getKey(),
|
||||||
@ -192,4 +197,24 @@ private function resolvePrincipalType(array $principal): string
|
|||||||
default => 'unknown',
|
default => 'unknown',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function recordStoredReportTelemetry(StoredReport $report, ?OperationRun $operationRun): void
|
||||||
|
{
|
||||||
|
if (! $operationRun instanceof OperationRun || ! is_numeric($operationRun->user_id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->productTelemetryRecorder->record(
|
||||||
|
eventName: ProductUsageEventCatalog::STORED_REPORT_CREATED,
|
||||||
|
workspaceId: (int) $report->workspace_id,
|
||||||
|
tenantId: (int) $report->tenant_id,
|
||||||
|
userId: (int) $operationRun->user_id,
|
||||||
|
subjectType: 'stored_report',
|
||||||
|
subjectId: (int) $report->getKey(),
|
||||||
|
metadata: [
|
||||||
|
'report_type' => $report->report_type,
|
||||||
|
],
|
||||||
|
occurredAt: $report->created_at ?? now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -179,5 +179,7 @@ private function persistDraft(TenantOnboardingSession $draft, bool $incrementVer
|
|||||||
$this->lifecycleService->applySnapshot($draft, false);
|
$this->lifecycleService->applySnapshot($draft, false);
|
||||||
|
|
||||||
$draft->save();
|
$draft->save();
|
||||||
|
|
||||||
|
$this->lifecycleService->recordCompletedCheckpointTelemetryIfNeeded($draft);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,12 +15,15 @@
|
|||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
|
||||||
|
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||||
use App\Support\Verification\VerificationReportOverall;
|
use App\Support\Verification\VerificationReportOverall;
|
||||||
|
|
||||||
class OnboardingLifecycleService
|
class OnboardingLifecycleService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantOperabilityService $tenantOperabilityService,
|
private readonly TenantOperabilityService $tenantOperabilityService,
|
||||||
|
private readonly ProductTelemetryRecorder $productTelemetryRecorder,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function syncPersistedLifecycle(TenantOnboardingSession $draft, bool $incrementVersion = false): TenantOnboardingSession
|
public function syncPersistedLifecycle(TenantOnboardingSession $draft, bool $incrementVersion = false): TenantOnboardingSession
|
||||||
@ -35,6 +38,7 @@ public function syncPersistedLifecycle(TenantOnboardingSession $draft, bool $inc
|
|||||||
|
|
||||||
if ($changed) {
|
if ($changed) {
|
||||||
$freshDraft->save();
|
$freshDraft->save();
|
||||||
|
$this->recordCompletedCheckpointTelemetryIfNeeded($freshDraft);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $freshDraft->refresh();
|
return $freshDraft->refresh();
|
||||||
@ -94,6 +98,46 @@ public function applySnapshot(TenantOnboardingSession $draft, bool $incrementVer
|
|||||||
return $changed;
|
return $changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function recordCompletedCheckpointTelemetryIfNeeded(TenantOnboardingSession $draft): void
|
||||||
|
{
|
||||||
|
if (! $draft->wasChanged('last_completed_checkpoint')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$checkpoint = $draft->last_completed_checkpoint instanceof OnboardingCheckpoint
|
||||||
|
? $draft->last_completed_checkpoint
|
||||||
|
: OnboardingCheckpoint::tryFrom((string) $draft->last_completed_checkpoint);
|
||||||
|
|
||||||
|
if (! $checkpoint instanceof OnboardingCheckpoint) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = (int) ($draft->workspace_id ?? 0);
|
||||||
|
$tenantId = (int) ($draft->tenant_id ?? 0);
|
||||||
|
$userId = (int) ($draft->updated_by_user_id ?? 0);
|
||||||
|
|
||||||
|
if ($workspaceId <= 0 || $tenantId <= 0 || $userId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$occurredAt = $draft->updated_at ?? now();
|
||||||
|
|
||||||
|
$this->productTelemetryRecorder->record(
|
||||||
|
eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
userId: $userId,
|
||||||
|
subjectType: 'tenant_onboarding_session',
|
||||||
|
subjectId: (int) $draft->getKey(),
|
||||||
|
metadata: [
|
||||||
|
'checkpoint_key' => $checkpoint->value,
|
||||||
|
'lifecycle_state' => $draft->lifecycleState()->value,
|
||||||
|
'completed_at' => $occurredAt,
|
||||||
|
],
|
||||||
|
occurredAt: $occurredAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* lifecycle_state: OnboardingLifecycleState,
|
* lifecycle_state: OnboardingLifecycleState,
|
||||||
|
|||||||
@ -23,6 +23,8 @@
|
|||||||
use App\Support\OpsUx\BulkRunContext;
|
use App\Support\OpsUx\BulkRunContext;
|
||||||
use App\Support\OpsUx\RunFailureSanitizer;
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||||
|
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
|
||||||
|
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Support\RbacReason;
|
use App\Support\RbacReason;
|
||||||
use App\Support\ReasonTranslation\NextStepOption;
|
use App\Support\ReasonTranslation\NextStepOption;
|
||||||
@ -44,6 +46,7 @@ public function __construct(
|
|||||||
private readonly AuditRecorder $auditRecorder,
|
private readonly AuditRecorder $auditRecorder,
|
||||||
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
|
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
|
||||||
private readonly ReasonTranslator $reasonTranslator,
|
private readonly ReasonTranslator $reasonTranslator,
|
||||||
|
private readonly ProductTelemetryRecorder $productTelemetryRecorder,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool
|
public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool
|
||||||
@ -139,7 +142,7 @@ public function ensureRun(
|
|||||||
|
|
||||||
// Create new run (race-safe via partial unique index)
|
// Create new run (race-safe via partial unique index)
|
||||||
try {
|
try {
|
||||||
return OperationRun::create([
|
$run = OperationRun::create([
|
||||||
'workspace_id' => $workspaceId,
|
'workspace_id' => $workspaceId,
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'user_id' => $initiator?->id,
|
'user_id' => $initiator?->id,
|
||||||
@ -150,6 +153,10 @@ public function ensureRun(
|
|||||||
'run_identity_hash' => $hash,
|
'run_identity_hash' => $hash,
|
||||||
'context' => $inputs,
|
'context' => $inputs,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->recordOperationStartedTelemetry($run, $initiator);
|
||||||
|
|
||||||
|
return $run;
|
||||||
} catch (QueryException $e) {
|
} catch (QueryException $e) {
|
||||||
// Unique violation (active-run dedupe):
|
// Unique violation (active-run dedupe):
|
||||||
// - PostgreSQL: 23505
|
// - PostgreSQL: 23505
|
||||||
@ -205,7 +212,7 @@ public function ensureRunWithIdentity(
|
|||||||
|
|
||||||
// Create new run (race-safe via partial unique index)
|
// Create new run (race-safe via partial unique index)
|
||||||
try {
|
try {
|
||||||
return OperationRun::create([
|
$run = OperationRun::create([
|
||||||
'workspace_id' => $workspaceId,
|
'workspace_id' => $workspaceId,
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'user_id' => $initiator?->id,
|
'user_id' => $initiator?->id,
|
||||||
@ -216,6 +223,10 @@ public function ensureRunWithIdentity(
|
|||||||
'run_identity_hash' => $hash,
|
'run_identity_hash' => $hash,
|
||||||
'context' => $context,
|
'context' => $context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->recordOperationStartedTelemetry($run, $initiator);
|
||||||
|
|
||||||
|
return $run;
|
||||||
} catch (QueryException $e) {
|
} catch (QueryException $e) {
|
||||||
// Unique violation (active-run dedupe):
|
// Unique violation (active-run dedupe):
|
||||||
// - PostgreSQL: 23505
|
// - PostgreSQL: 23505
|
||||||
@ -336,7 +347,7 @@ public function ensureRunWithIdentityStrict(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return OperationRun::create([
|
$run = OperationRun::create([
|
||||||
'workspace_id' => $workspaceId,
|
'workspace_id' => $workspaceId,
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'user_id' => $initiator?->id,
|
'user_id' => $initiator?->id,
|
||||||
@ -347,6 +358,10 @@ public function ensureRunWithIdentityStrict(
|
|||||||
'run_identity_hash' => $hash,
|
'run_identity_hash' => $hash,
|
||||||
'context' => $context,
|
'context' => $context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->recordOperationStartedTelemetry($run, $initiator);
|
||||||
|
|
||||||
|
return $run;
|
||||||
} catch (QueryException $e) {
|
} catch (QueryException $e) {
|
||||||
if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) {
|
if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) {
|
||||||
throw $e;
|
throw $e;
|
||||||
@ -1032,6 +1047,30 @@ private function normalizeExecutionContext(string $type, array $context, ?User $
|
|||||||
return $context;
|
return $context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function recordOperationStartedTelemetry(OperationRun $run, ?User $initiator): void
|
||||||
|
{
|
||||||
|
if (! $initiator instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_numeric($run->workspace_id) || ! is_numeric($run->tenant_id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->productTelemetryRecorder->record(
|
||||||
|
eventName: ProductUsageEventCatalog::OPERATIONS_STARTED,
|
||||||
|
workspaceId: (int) $run->workspace_id,
|
||||||
|
tenantId: (int) $run->tenant_id,
|
||||||
|
userId: (int) $initiator->getKey(),
|
||||||
|
subjectType: 'operation_run',
|
||||||
|
subjectId: (int) $run->getKey(),
|
||||||
|
metadata: [
|
||||||
|
'operation_type' => (string) $run->type,
|
||||||
|
],
|
||||||
|
occurredAt: $run->created_at ?? now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize inputs for stable identity hashing.
|
* Normalize inputs for stable identity hashing.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -11,6 +11,8 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Findings\FindingSlaPolicy;
|
use App\Services\Findings\FindingSlaPolicy;
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
use App\Services\Findings\FindingWorkflowService;
|
||||||
|
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
|
||||||
|
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -22,6 +24,7 @@ final class PermissionPostureFindingGenerator implements FindingGeneratorContrac
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PostureScoreCalculator $scoreCalculator,
|
private readonly PostureScoreCalculator $scoreCalculator,
|
||||||
private readonly FindingSlaPolicy $slaPolicy,
|
private readonly FindingSlaPolicy $slaPolicy,
|
||||||
|
private readonly ProductTelemetryRecorder $productTelemetryRecorder,
|
||||||
private readonly ?FindingWorkflowService $findingWorkflowService = null,
|
private readonly ?FindingWorkflowService $findingWorkflowService = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -94,6 +97,7 @@ public function generate(Tenant $tenant, array $permissionComparison, ?Operation
|
|||||||
$postureScore = $this->scoreCalculator->calculate($permissionComparison);
|
$postureScore = $this->scoreCalculator->calculate($permissionComparison);
|
||||||
|
|
||||||
$report = $this->createStoredReport($tenant, $permissionComparison, $permissions, $postureScore);
|
$report = $this->createStoredReport($tenant, $permissionComparison, $permissions, $postureScore);
|
||||||
|
$this->recordStoredReportTelemetry($report, $operationRun);
|
||||||
|
|
||||||
return new PostureResult(
|
return new PostureResult(
|
||||||
findingsCreated: $created,
|
findingsCreated: $created,
|
||||||
@ -404,6 +408,26 @@ private function createStoredReport(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function recordStoredReportTelemetry(StoredReport $report, ?OperationRun $operationRun): void
|
||||||
|
{
|
||||||
|
if (! $operationRun instanceof OperationRun || ! is_numeric($operationRun->user_id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->productTelemetryRecorder->record(
|
||||||
|
eventName: ProductUsageEventCatalog::STORED_REPORT_CREATED,
|
||||||
|
workspaceId: (int) $report->workspace_id,
|
||||||
|
tenantId: (int) $report->tenant_id,
|
||||||
|
userId: (int) $operationRun->user_id,
|
||||||
|
subjectType: 'stored_report',
|
||||||
|
subjectId: (int) $report->getKey(),
|
||||||
|
metadata: [
|
||||||
|
'report_type' => $report->report_type,
|
||||||
|
],
|
||||||
|
occurredAt: $report->created_at ?? now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
||||||
use App\Jobs\GenerateReviewPackJob;
|
use App\Jobs\GenerateReviewPackJob;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
@ -13,10 +14,13 @@
|
|||||||
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;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
|
||||||
|
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
@ -26,6 +30,8 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -46,12 +52,17 @@ 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);
|
||||||
|
|
||||||
$existing = $this->findExistingPack($tenant, $fingerprint);
|
$existing = $this->findExistingPack($tenant, $fingerprint);
|
||||||
|
|
||||||
if ($existing instanceof ReviewPack) {
|
if ($existing instanceof ReviewPack) {
|
||||||
|
$this->recordReviewPackRequestTelemetry($existing, $user, 'tenant');
|
||||||
|
|
||||||
return $existing;
|
return $existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,6 +81,8 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
|
|||||||
$queuedPack = $this->findPackForRun($tenant, $operationRun);
|
$queuedPack = $this->findPackForRun($tenant, $operationRun);
|
||||||
|
|
||||||
if ($queuedPack instanceof ReviewPack) {
|
if ($queuedPack instanceof ReviewPack) {
|
||||||
|
$this->recordReviewPackRequestTelemetry($queuedPack, $user, 'tenant');
|
||||||
|
|
||||||
return $queuedPack;
|
return $queuedPack;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -109,6 +122,8 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->recordReviewPackRequestTelemetry($reviewPack, $user, 'tenant');
|
||||||
|
|
||||||
return $reviewPack;
|
return $reviewPack;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,12 +143,15 @@ 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);
|
||||||
|
|
||||||
if ($existing instanceof ReviewPack) {
|
if ($existing instanceof ReviewPack) {
|
||||||
$this->logReviewExport($review, $user, $existing, 'reused');
|
$this->logReviewExport($review, $user, $existing, 'reused');
|
||||||
|
$this->recordReviewPackRequestTelemetry($existing, $user, 'tenant_review');
|
||||||
|
|
||||||
return $existing;
|
return $existing;
|
||||||
}
|
}
|
||||||
@ -155,6 +173,7 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
|
|||||||
|
|
||||||
if ($queuedPack instanceof ReviewPack) {
|
if ($queuedPack instanceof ReviewPack) {
|
||||||
$this->logReviewExport($review, $user, $queuedPack, 'reused_active_run');
|
$this->logReviewExport($review, $user, $queuedPack, 'reused_active_run');
|
||||||
|
$this->recordReviewPackRequestTelemetry($queuedPack, $user, 'tenant_review');
|
||||||
|
|
||||||
return $queuedPack;
|
return $queuedPack;
|
||||||
}
|
}
|
||||||
@ -198,6 +217,7 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
|
|||||||
});
|
});
|
||||||
|
|
||||||
$this->logReviewExport($review, $user, $reviewPack, 'queued');
|
$this->logReviewExport($review, $user, $reviewPack, 'queued');
|
||||||
|
$this->recordReviewPackRequestTelemetry($reviewPack, $user, 'tenant_review');
|
||||||
|
|
||||||
return $reviewPack;
|
return $reviewPack;
|
||||||
}
|
}
|
||||||
@ -214,18 +234,49 @@ public function computeFingerprint(Tenant $tenant, array $options): string
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a signed download URL for a review pack.
|
* Generate a signed download URL for a review pack.
|
||||||
|
*
|
||||||
|
* @param array<string, scalar|null> $parameters
|
||||||
*/
|
*/
|
||||||
public function generateDownloadUrl(ReviewPack $pack): string
|
public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): string
|
||||||
{
|
{
|
||||||
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
|
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
|
||||||
|
|
||||||
return URL::signedRoute(
|
return URL::signedRoute(
|
||||||
'admin.review-packs.download',
|
'admin.review-packs.download',
|
||||||
['reviewPack' => $pack->getKey()],
|
array_merge(['reviewPack' => $pack->getKey()], $parameters),
|
||||||
now()->addMinutes($ttlMinutes),
|
now()->addMinutes($ttlMinutes),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
{
|
||||||
|
$this->productTelemetryRecorder->record(
|
||||||
|
eventName: ProductUsageEventCatalog::REVIEW_PACK_REQUESTED,
|
||||||
|
workspaceId: (int) $reviewPack->workspace_id,
|
||||||
|
tenantId: (int) $reviewPack->tenant_id,
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
subjectType: 'review_pack',
|
||||||
|
subjectId: (int) $reviewPack->getKey(),
|
||||||
|
metadata: [
|
||||||
|
'source_surface' => $sourceSurface,
|
||||||
|
'include_operations' => (bool) ($reviewPack->options['include_operations'] ?? false),
|
||||||
|
'include_pii' => (bool) ($reviewPack->options['include_pii'] ?? false),
|
||||||
|
],
|
||||||
|
occurredAt: $reviewPack->created_at ?? now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find an existing ready, non-expired pack with the same fingerprint.
|
* Find an existing ready, non-expired pack with the same fingerprint.
|
||||||
*/
|
*/
|
||||||
@ -283,6 +334,17 @@ private function normalizeOptions(array $options): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function assertReviewPackGenerationAllowed(Tenant $tenant): void
|
||||||
|
{
|
||||||
|
$decision = $this->reviewPackGenerationDecisionForTenant($tenant);
|
||||||
|
|
||||||
|
if (! (bool) ($decision['is_blocked'] ?? false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new WorkspaceEntitlementBlockedException($decision);
|
||||||
|
}
|
||||||
|
|
||||||
private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string
|
private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string
|
||||||
{
|
{
|
||||||
$data = [
|
$data = [
|
||||||
|
|||||||
@ -10,15 +10,24 @@
|
|||||||
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\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Notifications\OperationRunCompleted;
|
use App\Notifications\OperationRunCompleted;
|
||||||
use App\Services\Alerts\AlertDispatchService;
|
use App\Services\Alerts\AlertDispatchService;
|
||||||
|
use App\Services\Audit\AuditRecorder;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Auth\BreakGlassSession;
|
use App\Services\Auth\BreakGlassSession;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\System\AllowedTenantUniverse;
|
use App\Services\System\AllowedTenantUniverse;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Audit\AuditActorSnapshot;
|
||||||
|
use App\Support\Audit\AuditTargetSnapshot;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||||
|
use App\Support\OperationalControls\OperationalControlEvaluator;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@ -35,6 +44,9 @@ public function __construct(
|
|||||||
private readonly OperationRunService $operationRunService,
|
private readonly OperationRunService $operationRunService,
|
||||||
private readonly AuditLogger $auditLogger,
|
private readonly AuditLogger $auditLogger,
|
||||||
private readonly AlertDispatchService $alertDispatchService,
|
private readonly AlertDispatchService $alertDispatchService,
|
||||||
|
private readonly OperationalControlEvaluator $operationalControls,
|
||||||
|
private readonly AuditRecorder $auditRecorder,
|
||||||
|
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -48,6 +60,7 @@ public function preflight(FindingsLifecycleBackfillScope $scope): array
|
|||||||
action: 'platform.ops.runbooks.preflight',
|
action: 'platform.ops.runbooks.preflight',
|
||||||
scope: $scope,
|
scope: $scope,
|
||||||
operationRunId: null,
|
operationRunId: null,
|
||||||
|
initiator: null,
|
||||||
context: [
|
context: [
|
||||||
'preflight' => $result,
|
'preflight' => $result,
|
||||||
],
|
],
|
||||||
@ -58,7 +71,7 @@ public function preflight(FindingsLifecycleBackfillScope $scope): array
|
|||||||
|
|
||||||
public function start(
|
public function start(
|
||||||
FindingsLifecycleBackfillScope $scope,
|
FindingsLifecycleBackfillScope $scope,
|
||||||
?PlatformUser $initiator,
|
User|PlatformUser|null $initiator,
|
||||||
?RunbookReason $reason,
|
?RunbookReason $reason,
|
||||||
string $source,
|
string $source,
|
||||||
): OperationRun {
|
): OperationRun {
|
||||||
@ -88,13 +101,41 @@ public function start(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$platformTenant = $this->platformTenant();
|
$workspace = null;
|
||||||
$workspace = $platformTenant->workspace;
|
$tenant = null;
|
||||||
|
|
||||||
|
if ($scope->isSingleTenant()) {
|
||||||
|
$tenant = Tenant::query()->whereKey((int) $scope->tenantId)->firstOrFail();
|
||||||
|
$this->allowedTenantUniverse->ensureAllowed($tenant);
|
||||||
|
|
||||||
|
$workspace = $tenant->workspace;
|
||||||
|
} else {
|
||||||
|
$platformTenant = $this->platformTenant();
|
||||||
|
$workspace = $platformTenant->workspace;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
if (! $workspace instanceof Workspace) {
|
||||||
throw new \RuntimeException('Platform tenant is missing its workspace.');
|
throw new \RuntimeException('Platform tenant is missing its workspace.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$decision = $this->operationalControls->evaluate(self::RUNBOOK_KEY, $workspace);
|
||||||
|
|
||||||
|
if ($decision->isPaused()) {
|
||||||
|
$this->auditBlockedStart(
|
||||||
|
decision: $decision,
|
||||||
|
scope: $scope,
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: $tenant,
|
||||||
|
initiator: $initiator,
|
||||||
|
source: $source,
|
||||||
|
);
|
||||||
|
|
||||||
|
throw OperationalControlBlockedException::forDecision(
|
||||||
|
decision: $decision,
|
||||||
|
actionLabel: OperationCatalog::label(self::RUNBOOK_KEY),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($scope->isAllTenants()) {
|
if ($scope->isAllTenants()) {
|
||||||
$lockKey = sprintf('tenantpilot:runbooks:%s:workspace:%d', self::RUNBOOK_KEY, (int) $workspace->getKey());
|
$lockKey = sprintf('tenantpilot:runbooks:%s:workspace:%d', self::RUNBOOK_KEY, (int) $workspace->getKey());
|
||||||
$lock = Cache::lock($lockKey, 900);
|
$lock = Cache::lock($lockKey, 900);
|
||||||
@ -120,7 +161,7 @@ public function start(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $this->startSingleTenant(
|
return $this->startSingleTenant(
|
||||||
tenantId: (int) $scope->tenantId,
|
tenant: $tenant,
|
||||||
initiator: $initiator,
|
initiator: $initiator,
|
||||||
reason: $reason,
|
reason: $reason,
|
||||||
preflight: $preflight,
|
preflight: $preflight,
|
||||||
@ -327,7 +368,7 @@ private function countDriftDuplicateConsolidations(Tenant $tenant): int
|
|||||||
|
|
||||||
private function startAllTenants(
|
private function startAllTenants(
|
||||||
Workspace $workspace,
|
Workspace $workspace,
|
||||||
?PlatformUser $initiator,
|
User|PlatformUser|null $initiator,
|
||||||
?RunbookReason $reason,
|
?RunbookReason $reason,
|
||||||
array $preflight,
|
array $preflight,
|
||||||
string $source,
|
string $source,
|
||||||
@ -349,7 +390,7 @@ private function startAllTenants(
|
|||||||
source: $source,
|
source: $source,
|
||||||
isBreakGlassActive: $isBreakGlassActive,
|
isBreakGlassActive: $isBreakGlassActive,
|
||||||
),
|
),
|
||||||
initiator: null,
|
initiator: $initiator instanceof User ? $initiator : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) {
|
if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) {
|
||||||
@ -361,6 +402,7 @@ private function startAllTenants(
|
|||||||
action: 'platform.ops.runbooks.start',
|
action: 'platform.ops.runbooks.start',
|
||||||
scope: FindingsLifecycleBackfillScope::allTenants(),
|
scope: FindingsLifecycleBackfillScope::allTenants(),
|
||||||
operationRunId: (int) $run->getKey(),
|
operationRunId: (int) $run->getKey(),
|
||||||
|
initiator: $initiator,
|
||||||
context: [
|
context: [
|
||||||
'preflight' => $preflight,
|
'preflight' => $preflight,
|
||||||
'is_break_glass' => $isBreakGlassActive,
|
'is_break_glass' => $isBreakGlassActive,
|
||||||
@ -382,15 +424,16 @@ private function startAllTenants(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function startSingleTenant(
|
private function startSingleTenant(
|
||||||
int $tenantId,
|
?Tenant $tenant,
|
||||||
?PlatformUser $initiator,
|
User|PlatformUser|null $initiator,
|
||||||
?RunbookReason $reason,
|
?RunbookReason $reason,
|
||||||
array $preflight,
|
array $preflight,
|
||||||
string $source,
|
string $source,
|
||||||
bool $isBreakGlassActive,
|
bool $isBreakGlassActive,
|
||||||
): OperationRun {
|
): OperationRun {
|
||||||
$tenant = Tenant::query()->whereKey($tenantId)->firstOrFail();
|
if (! $tenant instanceof Tenant) {
|
||||||
$this->allowedTenantUniverse->ensureAllowed($tenant);
|
throw new \RuntimeException('Target tenant is required for single-tenant runs.');
|
||||||
|
}
|
||||||
|
|
||||||
$run = $this->operationRunService->ensureRunWithIdentity(
|
$run = $this->operationRunService->ensureRunWithIdentity(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -408,7 +451,7 @@ private function startSingleTenant(
|
|||||||
source: $source,
|
source: $source,
|
||||||
isBreakGlassActive: $isBreakGlassActive,
|
isBreakGlassActive: $isBreakGlassActive,
|
||||||
),
|
),
|
||||||
initiator: null,
|
initiator: $initiator instanceof User ? $initiator : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) {
|
if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) {
|
||||||
@ -420,6 +463,7 @@ private function startSingleTenant(
|
|||||||
action: 'platform.ops.runbooks.start',
|
action: 'platform.ops.runbooks.start',
|
||||||
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||||
operationRunId: (int) $run->getKey(),
|
operationRunId: (int) $run->getKey(),
|
||||||
|
initiator: $initiator,
|
||||||
context: [
|
context: [
|
||||||
'preflight' => $preflight,
|
'preflight' => $preflight,
|
||||||
'is_break_glass' => $isBreakGlassActive,
|
'is_break_glass' => $isBreakGlassActive,
|
||||||
@ -458,7 +502,7 @@ private function platformTenant(): Tenant
|
|||||||
private function buildRunContext(
|
private function buildRunContext(
|
||||||
int $workspaceId,
|
int $workspaceId,
|
||||||
FindingsLifecycleBackfillScope $scope,
|
FindingsLifecycleBackfillScope $scope,
|
||||||
?PlatformUser $initiator,
|
User|PlatformUser|null $initiator,
|
||||||
?RunbookReason $reason,
|
?RunbookReason $reason,
|
||||||
array $preflight,
|
array $preflight,
|
||||||
string $source,
|
string $source,
|
||||||
@ -490,6 +534,12 @@ private function buildRunContext(
|
|||||||
'name' => (string) $initiator->name,
|
'name' => (string) $initiator->name,
|
||||||
'is_break_glass' => $isBreakGlassActive,
|
'is_break_glass' => $isBreakGlassActive,
|
||||||
];
|
];
|
||||||
|
} elseif ($initiator instanceof User) {
|
||||||
|
$context['tenant_initiator'] = [
|
||||||
|
'user_id' => (int) $initiator->getKey(),
|
||||||
|
'email' => (string) $initiator->email,
|
||||||
|
'name' => (string) $initiator->name,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $context;
|
return $context;
|
||||||
@ -514,23 +564,10 @@ private function auditSafely(
|
|||||||
string $action,
|
string $action,
|
||||||
FindingsLifecycleBackfillScope $scope,
|
FindingsLifecycleBackfillScope $scope,
|
||||||
?int $operationRunId,
|
?int $operationRunId,
|
||||||
|
User|PlatformUser|null $initiator,
|
||||||
array $context = [],
|
array $context = [],
|
||||||
): void {
|
): void {
|
||||||
try {
|
try {
|
||||||
$platformTenant = $this->platformTenant();
|
|
||||||
|
|
||||||
$actor = auth('platform')->user();
|
|
||||||
|
|
||||||
$actorId = null;
|
|
||||||
$actorEmail = null;
|
|
||||||
$actorName = null;
|
|
||||||
|
|
||||||
if ($actor instanceof PlatformUser) {
|
|
||||||
$actorId = (int) $actor->getKey();
|
|
||||||
$actorEmail = (string) $actor->email;
|
|
||||||
$actorName = (string) $actor->name;
|
|
||||||
}
|
|
||||||
|
|
||||||
$metadata = [
|
$metadata = [
|
||||||
'runbook_key' => self::RUNBOOK_KEY,
|
'runbook_key' => self::RUNBOOK_KEY,
|
||||||
'scope' => $scope->mode,
|
'scope' => $scope->mode,
|
||||||
@ -540,6 +577,37 @@ private function auditSafely(
|
|||||||
'user_agent' => request()->userAgent(),
|
'user_agent' => request()->userAgent(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($initiator instanceof User && $scope->isSingleTenant()) {
|
||||||
|
$tenant = Tenant::query()->whereKey((int) $scope->tenantId)->first();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$this->auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: $action,
|
||||||
|
context: [
|
||||||
|
'metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null),
|
||||||
|
] + $context,
|
||||||
|
actorId: (int) $initiator->getKey(),
|
||||||
|
actorEmail: (string) $initiator->email,
|
||||||
|
actorName: (string) $initiator->name,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'operation_run',
|
||||||
|
resourceId: $operationRunId !== null ? (string) $operationRunId : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$platformTenant = $this->platformTenant();
|
||||||
|
$platformActor = $initiator instanceof PlatformUser
|
||||||
|
? $initiator
|
||||||
|
: auth('platform')->user();
|
||||||
|
|
||||||
|
$actorId = $platformActor instanceof PlatformUser ? (int) $platformActor->getKey() : null;
|
||||||
|
$actorEmail = $platformActor instanceof PlatformUser ? (string) $platformActor->email : null;
|
||||||
|
$actorName = $platformActor instanceof PlatformUser ? (string) $platformActor->name : null;
|
||||||
|
|
||||||
$this->auditLogger->log(
|
$this->auditLogger->log(
|
||||||
tenant: $platformTenant,
|
tenant: $platformTenant,
|
||||||
action: $action,
|
action: $action,
|
||||||
@ -558,6 +626,68 @@ private function auditSafely(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function auditBlockedStart(
|
||||||
|
\App\Support\OperationalControls\OperationalControlDecision $decision,
|
||||||
|
FindingsLifecycleBackfillScope $scope,
|
||||||
|
Workspace $workspace,
|
||||||
|
?Tenant $tenant,
|
||||||
|
User|PlatformUser|null $initiator,
|
||||||
|
string $source,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
$metadata = array_filter([
|
||||||
|
'control_key' => $decision->controlKey,
|
||||||
|
'scope_type' => $decision->matchedScopeType,
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'reason_text' => $decision->reasonText,
|
||||||
|
'expires_at' => $decision->expiresAt?->toIso8601String(),
|
||||||
|
'actor_id' => $initiator instanceof User || $initiator instanceof PlatformUser ? (int) $initiator->getKey() : null,
|
||||||
|
'requested_scope' => $scope->mode,
|
||||||
|
'target_tenant_id' => $scope->tenantId,
|
||||||
|
'source' => $source,
|
||||||
|
'runbook_key' => self::RUNBOOK_KEY,
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||||
|
|
||||||
|
$summary = sprintf('%s blocked by operational control', OperationCatalog::label(self::RUNBOOK_KEY));
|
||||||
|
|
||||||
|
if ($scope->isAllTenants()) {
|
||||||
|
$this->auditRecorder->record(
|
||||||
|
action: AuditActionId::OperationalControlExecutionBlocked,
|
||||||
|
context: ['metadata' => $metadata],
|
||||||
|
actor: $initiator instanceof PlatformUser ? AuditActorSnapshot::platform($initiator) : null,
|
||||||
|
target: new AuditTargetSnapshot(
|
||||||
|
type: 'operational_control',
|
||||||
|
id: $decision->sourceActivationId,
|
||||||
|
label: OperationCatalog::label(self::RUNBOOK_KEY),
|
||||||
|
),
|
||||||
|
outcome: 'blocked',
|
||||||
|
summary: $summary,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->workspaceAuditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::OperationalControlExecutionBlocked,
|
||||||
|
context: ['metadata' => $metadata],
|
||||||
|
actor: $initiator,
|
||||||
|
status: 'blocked',
|
||||||
|
resourceType: 'operational_control',
|
||||||
|
resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null,
|
||||||
|
targetLabel: OperationCatalog::label(self::RUNBOOK_KEY),
|
||||||
|
summary: $summary,
|
||||||
|
tenant: $tenant,
|
||||||
|
);
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Audit is fail-safe (must not crash runbooks).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function notifyInitiatorSafely(OperationRun $run): void
|
private function notifyInitiatorSafely(OperationRun $run): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Services\Auth\RoleCapabilityMap;
|
use App\Services\Auth\RoleCapabilityMap;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
final class TenantReviewRegisterService
|
final class TenantReviewRegisterService
|
||||||
{
|
{
|
||||||
@ -43,6 +44,55 @@ public function query(User $user, Workspace $workspace): Builder
|
|||||||
->latest('id');
|
->latest('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function latestPublishedQuery(User $user, Workspace $workspace): Builder
|
||||||
|
{
|
||||||
|
$tenantIds = array_keys($this->authorizedTenants($user, $workspace));
|
||||||
|
|
||||||
|
$rankedReviews = TenantReview::query()
|
||||||
|
->select([
|
||||||
|
'tenant_reviews.id',
|
||||||
|
'tenant_reviews.tenant_id',
|
||||||
|
'tenant_reviews.published_at',
|
||||||
|
'tenant_reviews.generated_at',
|
||||||
|
])
|
||||||
|
->selectRaw('ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY published_at DESC, generated_at DESC, id DESC) as rn')
|
||||||
|
->forWorkspace((int) $workspace->getKey())
|
||||||
|
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||||
|
->published();
|
||||||
|
|
||||||
|
$latestPublishedIds = DB::query()
|
||||||
|
->fromSub($rankedReviews, 'ranked_tenant_reviews')
|
||||||
|
->where('rn', 1)
|
||||||
|
->select('id');
|
||||||
|
|
||||||
|
return TenantReview::query()
|
||||||
|
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
|
||||||
|
->forWorkspace((int) $workspace->getKey())
|
||||||
|
->whereIn('tenant_reviews.id', $latestPublishedIds)
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->orderByDesc('generated_at')
|
||||||
|
->orderByDesc('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customerWorkspaceTenantQuery(User $user, Workspace $workspace): Builder
|
||||||
|
{
|
||||||
|
$tenantIds = array_keys($this->authorizedTenants($user, $workspace));
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->whereIn('id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||||
|
->with([
|
||||||
|
'tenantReviews' => fn ($query) => $query
|
||||||
|
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
|
||||||
|
->published()
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->orderByDesc('generated_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->limit(1),
|
||||||
|
])
|
||||||
|
->orderBy('name');
|
||||||
|
}
|
||||||
|
|
||||||
public function canAccessWorkspace(User $user, Workspace $workspace): bool
|
public function canAccessWorkspace(User $user, Workspace $workspace): bool
|
||||||
{
|
{
|
||||||
return WorkspaceMembership::query()
|
return WorkspaceMembership::query()
|
||||||
|
|||||||
27
apps/platform/app/Support/Ai/AiDataClassification.php
Normal file
27
apps/platform/app/Support/Ai/AiDataClassification.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
enum AiDataClassification: string
|
||||||
|
{
|
||||||
|
case ProductKnowledge = 'product_knowledge';
|
||||||
|
case OperationalMetadata = 'operational_metadata';
|
||||||
|
case RedactedSupportSummary = 'redacted_support_summary';
|
||||||
|
case PersonalData = 'personal_data';
|
||||||
|
case CustomerConfidential = 'customer_confidential';
|
||||||
|
case RawProviderPayload = 'raw_provider_payload';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::ProductKnowledge => 'Product knowledge',
|
||||||
|
self::OperationalMetadata => 'Operational metadata',
|
||||||
|
self::RedactedSupportSummary => 'Redacted support summary',
|
||||||
|
self::PersonalData => 'Personal data',
|
||||||
|
self::CustomerConfidential => 'Customer confidential',
|
||||||
|
self::RawProviderPayload => 'Raw provider payload',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
final class AiDecisionAuditMetadataFactory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function make(AiExecutionRequest $request, AiExecutionDecision $decision): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'use_case_key' => $decision->useCaseKey,
|
||||||
|
'decision_outcome' => $decision->outcome,
|
||||||
|
'decision_reason' => $decision->reasonCode->value,
|
||||||
|
'workspace_ai_policy_mode' => $decision->workspaceAiPolicyMode,
|
||||||
|
'requested_provider_class' => $decision->requestedProviderClass,
|
||||||
|
'data_classifications' => $decision->dataClassifications,
|
||||||
|
'source_family' => $decision->sourceFamily,
|
||||||
|
'workspace_id' => $request->workspace?->getKey(),
|
||||||
|
'tenant_id' => $request->tenant?->getKey(),
|
||||||
|
'context_fingerprint' => $this->normalizedFingerprint($request->contextFingerprint),
|
||||||
|
'matched_operational_control_scope' => $decision->matchedOperationalControlScope,
|
||||||
|
], static fn (mixed $value): bool => $value !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizedFingerprint(?string $contextFingerprint): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($contextFingerprint)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim($contextFingerprint);
|
||||||
|
|
||||||
|
return $normalized === '' ? null : $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/platform/app/Support/Ai/AiDecisionReasonCode.php
Normal file
18
apps/platform/app/Support/Ai/AiDecisionReasonCode.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
enum AiDecisionReasonCode: string
|
||||||
|
{
|
||||||
|
case Allowed = 'allowed';
|
||||||
|
case MissingWorkspaceContext = 'missing_workspace_context';
|
||||||
|
case TenantOutsideWorkspace = 'tenant_outside_workspace';
|
||||||
|
case OperationalControlPaused = 'operational_control_paused';
|
||||||
|
case WorkspacePolicyDisabled = 'workspace_policy_disabled';
|
||||||
|
case UnregisteredUseCase = 'unregistered_use_case';
|
||||||
|
case ProviderClassBlocked = 'provider_class_blocked';
|
||||||
|
case DataClassificationBlocked = 'data_classification_blocked';
|
||||||
|
case SourceFamilyMismatch = 'source_family_mismatch';
|
||||||
|
}
|
||||||
37
apps/platform/app/Support/Ai/AiExecutionDecision.php
Normal file
37
apps/platform/app/Support/Ai/AiExecutionDecision.php
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
|
||||||
|
final readonly class AiExecutionDecision
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $dataClassifications
|
||||||
|
* @param array<string, mixed> $auditMetadata
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $outcome,
|
||||||
|
public AiDecisionReasonCode $reasonCode,
|
||||||
|
public string $workspaceAiPolicyMode,
|
||||||
|
public ?string $matchedOperationalControlScope,
|
||||||
|
public string $useCaseKey,
|
||||||
|
public string $requestedProviderClass,
|
||||||
|
public array $dataClassifications,
|
||||||
|
public string $sourceFamily,
|
||||||
|
public AuditActionId $auditAction,
|
||||||
|
public array $auditMetadata,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function isAllowed(): bool
|
||||||
|
{
|
||||||
|
return $this->outcome === 'allowed';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isBlocked(): bool
|
||||||
|
{
|
||||||
|
return $this->outcome === 'blocked';
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/platform/app/Support/Ai/AiExecutionRequest.php
Normal file
28
apps/platform/app/Support/Ai/AiExecutionRequest.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
|
||||||
|
final readonly class AiExecutionRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $dataClassifications
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public ?Workspace $workspace,
|
||||||
|
public ?Tenant $tenant,
|
||||||
|
public User|PlatformUser|null $actor,
|
||||||
|
public string $useCaseKey,
|
||||||
|
public string $requestedProviderClass,
|
||||||
|
public array $dataClassifications,
|
||||||
|
public string $sourceFamily,
|
||||||
|
public ?string $callerSurface = null,
|
||||||
|
public ?string $contextFingerprint = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
43
apps/platform/app/Support/Ai/AiPolicyMode.php
Normal file
43
apps/platform/app/Support/Ai/AiPolicyMode.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
enum AiPolicyMode: string
|
||||||
|
{
|
||||||
|
case Disabled = 'disabled';
|
||||||
|
case PrivateOnly = 'private_only';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Disabled => 'Disabled',
|
||||||
|
self::PrivateOnly => 'Private only',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function summary(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Disabled => 'No AI execution is allowed for this workspace.',
|
||||||
|
self::PrivateOnly => 'Only approved internal drafts may use private-only AI for approved use cases.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function optionLabels(): array
|
||||||
|
{
|
||||||
|
return array_reduce(
|
||||||
|
self::cases(),
|
||||||
|
static function (array $labels, self $mode): array {
|
||||||
|
$labels[$mode->value] = $mode->label();
|
||||||
|
|
||||||
|
return $labels;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/platform/app/Support/Ai/AiProviderClass.php
Normal file
19
apps/platform/app/Support/Ai/AiProviderClass.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
enum AiProviderClass: string
|
||||||
|
{
|
||||||
|
case LocalPrivate = 'local_private';
|
||||||
|
case ExternalPublic = 'external_public';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::LocalPrivate => 'Local private',
|
||||||
|
self::ExternalPublic => 'External public',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
126
apps/platform/app/Support/Ai/AiUseCaseCatalog.php
Normal file
126
apps/platform/app/Support/Ai/AiUseCaseCatalog.php
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
final class AiUseCaseCatalog
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, array{
|
||||||
|
* key: string,
|
||||||
|
* label: string,
|
||||||
|
* future_consumer: string,
|
||||||
|
* visibility: string,
|
||||||
|
* allowed_provider_classes: list<string>,
|
||||||
|
* allowed_data_classifications: list<string>,
|
||||||
|
* source_family: string,
|
||||||
|
* tenant_context_permitted: bool
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private const USE_CASES = [
|
||||||
|
'product_knowledge.answer_draft' => [
|
||||||
|
'key' => 'product_knowledge.answer_draft',
|
||||||
|
'label' => 'Product knowledge answer draft',
|
||||||
|
'future_consumer' => 'ContextualHelpResolver',
|
||||||
|
'visibility' => 'internal_only_draft',
|
||||||
|
'allowed_provider_classes' => [AiProviderClass::LocalPrivate->value],
|
||||||
|
'allowed_data_classifications' => [
|
||||||
|
AiDataClassification::ProductKnowledge->value,
|
||||||
|
AiDataClassification::OperationalMetadata->value,
|
||||||
|
],
|
||||||
|
'source_family' => 'product_knowledge',
|
||||||
|
'tenant_context_permitted' => false,
|
||||||
|
],
|
||||||
|
'support_diagnostics.summary_draft' => [
|
||||||
|
'key' => 'support_diagnostics.summary_draft',
|
||||||
|
'label' => 'Support diagnostics summary draft',
|
||||||
|
'future_consumer' => 'SupportDiagnosticBundleBuilder',
|
||||||
|
'visibility' => 'internal_only_draft',
|
||||||
|
'allowed_provider_classes' => [AiProviderClass::LocalPrivate->value],
|
||||||
|
'allowed_data_classifications' => [AiDataClassification::RedactedSupportSummary->value],
|
||||||
|
'source_family' => 'support_diagnostics',
|
||||||
|
'tenant_context_permitted' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* key: string,
|
||||||
|
* label: string,
|
||||||
|
* future_consumer: string,
|
||||||
|
* visibility: string,
|
||||||
|
* allowed_provider_classes: list<string>,
|
||||||
|
* allowed_data_classifications: list<string>,
|
||||||
|
* source_family: string,
|
||||||
|
* tenant_context_permitted: bool
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return array_values(self::USE_CASES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* key: string,
|
||||||
|
* label: string,
|
||||||
|
* future_consumer: string,
|
||||||
|
* visibility: string,
|
||||||
|
* allowed_provider_classes: list<string>,
|
||||||
|
* allowed_data_classifications: list<string>,
|
||||||
|
* source_family: string,
|
||||||
|
* tenant_context_permitted: bool
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public function find(string $key): ?array
|
||||||
|
{
|
||||||
|
return self::USE_CASES[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function labels(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (array $definition): string => $definition['label'],
|
||||||
|
$this->all(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function allowedProviderClassLabelsForMode(AiPolicyMode $mode): array
|
||||||
|
{
|
||||||
|
if ($mode === AiPolicyMode::Disabled) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
|
||||||
|
foreach ($this->all() as $definition) {
|
||||||
|
foreach ($definition['allowed_provider_classes'] as $providerClass) {
|
||||||
|
$labels[$providerClass] = AiProviderClass::from($providerClass)->label();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function blockedDataClassificationLabels(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (AiDataClassification $classification): string => $classification->label(),
|
||||||
|
[
|
||||||
|
AiDataClassification::PersonalData,
|
||||||
|
AiDataClassification::CustomerConfidential,
|
||||||
|
AiDataClassification::RawProviderPayload,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
apps/platform/app/Support/Ai/GovernedAiExecutionBoundary.php
Normal file
181
apps/platform/app/Support/Ai/GovernedAiExecutionBoundary.php
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ai;
|
||||||
|
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Settings\SettingsResolver;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\OperationalControls\OperationalControlEvaluator;
|
||||||
|
|
||||||
|
final class GovernedAiExecutionBoundary
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AiUseCaseCatalog $useCaseCatalog,
|
||||||
|
private readonly SettingsResolver $settingsResolver,
|
||||||
|
private readonly OperationalControlEvaluator $operationalControls,
|
||||||
|
private readonly AiDecisionAuditMetadataFactory $auditMetadataFactory,
|
||||||
|
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function evaluate(AiExecutionRequest $request): AiExecutionDecision
|
||||||
|
{
|
||||||
|
$decision = $this->decisionFor($request);
|
||||||
|
$metadata = $this->auditMetadataFactory->make($request, $decision);
|
||||||
|
|
||||||
|
$decision = new AiExecutionDecision(
|
||||||
|
outcome: $decision->outcome,
|
||||||
|
reasonCode: $decision->reasonCode,
|
||||||
|
workspaceAiPolicyMode: $decision->workspaceAiPolicyMode,
|
||||||
|
matchedOperationalControlScope: $decision->matchedOperationalControlScope,
|
||||||
|
useCaseKey: $decision->useCaseKey,
|
||||||
|
requestedProviderClass: $decision->requestedProviderClass,
|
||||||
|
dataClassifications: $decision->dataClassifications,
|
||||||
|
sourceFamily: $decision->sourceFamily,
|
||||||
|
auditAction: $decision->auditAction,
|
||||||
|
auditMetadata: $metadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($request->workspace !== null) {
|
||||||
|
$definition = $this->useCaseCatalog->find($request->useCaseKey);
|
||||||
|
|
||||||
|
$this->workspaceAuditLogger->log(
|
||||||
|
workspace: $request->workspace,
|
||||||
|
action: $decision->auditAction,
|
||||||
|
context: ['metadata' => $decision->auditMetadata],
|
||||||
|
actor: $request->actor,
|
||||||
|
status: $decision->isAllowed() ? 'success' : 'blocked',
|
||||||
|
resourceType: 'ai_use_case',
|
||||||
|
resourceId: $request->useCaseKey,
|
||||||
|
targetLabel: $definition['label'] ?? $request->useCaseKey,
|
||||||
|
summary: 'AI execution decision evaluated',
|
||||||
|
tenant: $request->tenant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decisionFor(AiExecutionRequest $request): AiExecutionDecision
|
||||||
|
{
|
||||||
|
if ($request->workspace === null) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::MissingWorkspaceContext,
|
||||||
|
workspaceAiPolicyMode: AiPolicyMode::Disabled->value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->tenant !== null && (int) $request->tenant->workspace_id !== (int) $request->workspace->getKey()) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::TenantOutsideWorkspace,
|
||||||
|
workspaceAiPolicyMode: AiPolicyMode::Disabled->value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$controlDecision = $this->operationalControls->evaluate('ai.execution', $request->workspace);
|
||||||
|
|
||||||
|
if ($controlDecision->isPaused()) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::OperationalControlPaused,
|
||||||
|
workspaceAiPolicyMode: $this->resolvedPolicyMode($request),
|
||||||
|
matchedOperationalControlScope: $controlDecision->matchedScopeType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$policyMode = $this->resolvedPolicyMode($request);
|
||||||
|
|
||||||
|
if ($policyMode === AiPolicyMode::Disabled->value) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::WorkspacePolicyDisabled,
|
||||||
|
workspaceAiPolicyMode: $policyMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$definition = $this->useCaseCatalog->find($request->useCaseKey);
|
||||||
|
|
||||||
|
if ($definition === null) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::UnregisteredUseCase,
|
||||||
|
workspaceAiPolicyMode: $policyMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($definition['source_family'] !== $request->sourceFamily) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::SourceFamilyMismatch,
|
||||||
|
workspaceAiPolicyMode: $policyMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($request->requestedProviderClass, $definition['allowed_provider_classes'], true)) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::ProviderClassBlocked,
|
||||||
|
workspaceAiPolicyMode: $policyMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($request->dataClassifications as $classification) {
|
||||||
|
if (! in_array($classification, $definition['allowed_data_classifications'], true)) {
|
||||||
|
return $this->blockedDecision(
|
||||||
|
request: $request,
|
||||||
|
reasonCode: AiDecisionReasonCode::DataClassificationBlocked,
|
||||||
|
workspaceAiPolicyMode: $policyMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AiExecutionDecision(
|
||||||
|
outcome: 'allowed',
|
||||||
|
reasonCode: AiDecisionReasonCode::Allowed,
|
||||||
|
workspaceAiPolicyMode: $policyMode,
|
||||||
|
matchedOperationalControlScope: null,
|
||||||
|
useCaseKey: $request->useCaseKey,
|
||||||
|
requestedProviderClass: $request->requestedProviderClass,
|
||||||
|
dataClassifications: $request->dataClassifications,
|
||||||
|
sourceFamily: $request->sourceFamily,
|
||||||
|
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
|
||||||
|
auditMetadata: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvedPolicyMode(AiExecutionRequest $request): string
|
||||||
|
{
|
||||||
|
if ($request->workspace === null) {
|
||||||
|
return AiPolicyMode::Disabled->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = $this->settingsResolver->resolveValue($request->workspace, 'ai', 'policy_mode');
|
||||||
|
|
||||||
|
return is_string($resolved) && $resolved !== ''
|
||||||
|
? $resolved
|
||||||
|
: AiPolicyMode::Disabled->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function blockedDecision(
|
||||||
|
AiExecutionRequest $request,
|
||||||
|
AiDecisionReasonCode $reasonCode,
|
||||||
|
string $workspaceAiPolicyMode,
|
||||||
|
?string $matchedOperationalControlScope = null,
|
||||||
|
): AiExecutionDecision {
|
||||||
|
return new AiExecutionDecision(
|
||||||
|
outcome: 'blocked',
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
workspaceAiPolicyMode: $workspaceAiPolicyMode,
|
||||||
|
matchedOperationalControlScope: $matchedOperationalControlScope,
|
||||||
|
useCaseKey: $request->useCaseKey,
|
||||||
|
requestedProviderClass: $request->requestedProviderClass,
|
||||||
|
dataClassifications: $request->dataClassifications,
|
||||||
|
sourceFamily: $request->sourceFamily,
|
||||||
|
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
|
||||||
|
auditMetadata: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -94,11 +94,21 @@ enum AuditActionId: string
|
|||||||
case TenantReviewRefreshed = 'tenant_review.refreshed';
|
case TenantReviewRefreshed = 'tenant_review.refreshed';
|
||||||
case TenantReviewPublished = 'tenant_review.published';
|
case TenantReviewPublished = 'tenant_review.published';
|
||||||
case TenantReviewArchived = 'tenant_review.archived';
|
case TenantReviewArchived = 'tenant_review.archived';
|
||||||
|
case TenantReviewOpened = 'tenant_review.opened';
|
||||||
case TenantReviewExported = 'tenant_review.exported';
|
case TenantReviewExported = 'tenant_review.exported';
|
||||||
case TenantReviewSuccessorCreated = 'tenant_review.successor_created';
|
case TenantReviewSuccessorCreated = 'tenant_review.successor_created';
|
||||||
|
case ReviewPackDownloaded = 'review_pack.downloaded';
|
||||||
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
|
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
|
||||||
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
|
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
|
||||||
|
|
||||||
|
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
|
||||||
|
case SupportRequestCreated = 'support_request.created';
|
||||||
|
case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated';
|
||||||
|
case OperationalControlPaused = 'operational_control.paused';
|
||||||
|
case OperationalControlUpdated = 'operational_control.updated';
|
||||||
|
case OperationalControlResumed = 'operational_control.resumed';
|
||||||
|
case OperationalControlExecutionBlocked = 'operational_control.execution_blocked';
|
||||||
|
|
||||||
// Workspace selection / switch events (Spec 107).
|
// Workspace selection / switch events (Spec 107).
|
||||||
case WorkspaceAutoSelected = 'workspace.auto_selected';
|
case WorkspaceAutoSelected = 'workspace.auto_selected';
|
||||||
case WorkspaceSelected = 'workspace.selected';
|
case WorkspaceSelected = 'workspace.selected';
|
||||||
@ -230,10 +240,19 @@ private static function labels(): array
|
|||||||
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
||||||
self::TenantReviewPublished->value => 'Tenant review published',
|
self::TenantReviewPublished->value => 'Tenant review published',
|
||||||
self::TenantReviewArchived->value => 'Tenant review archived',
|
self::TenantReviewArchived->value => 'Tenant review archived',
|
||||||
|
self::TenantReviewOpened->value => 'Tenant review opened',
|
||||||
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::ReviewPackDownloaded->value => 'Review pack downloaded',
|
||||||
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::SupportRequestCreated->value => 'Support request created',
|
||||||
|
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
|
||||||
|
self::OperationalControlPaused->value => 'Operational control paused',
|
||||||
|
self::OperationalControlUpdated->value => 'Operational control updated',
|
||||||
|
self::OperationalControlResumed->value => 'Operational control resumed',
|
||||||
|
self::OperationalControlExecutionBlocked->value => 'Operational control blocked execution',
|
||||||
'baseline.capture.started' => 'Baseline capture started',
|
'baseline.capture.started' => 'Baseline capture started',
|
||||||
'baseline.capture.completed' => 'Baseline capture completed',
|
'baseline.capture.completed' => 'Baseline capture completed',
|
||||||
'baseline.capture.failed' => 'Baseline capture failed',
|
'baseline.capture.failed' => 'Baseline capture failed',
|
||||||
@ -313,8 +332,17 @@ private static function summaries(): array
|
|||||||
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
||||||
self::TenantReviewPublished->value => 'Tenant review published',
|
self::TenantReviewPublished->value => 'Tenant review published',
|
||||||
self::TenantReviewArchived->value => 'Tenant review archived',
|
self::TenantReviewArchived->value => 'Tenant review archived',
|
||||||
|
self::TenantReviewOpened->value => 'Tenant review opened',
|
||||||
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::ReviewPackDownloaded->value => 'Review pack downloaded',
|
||||||
|
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
||||||
|
self::SupportRequestCreated->value => 'Support request created',
|
||||||
|
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
|
||||||
|
self::OperationalControlPaused->value => 'Operational control paused',
|
||||||
|
self::OperationalControlUpdated->value => 'Operational control updated',
|
||||||
|
self::OperationalControlResumed->value => 'Operational control resumed',
|
||||||
|
self::OperationalControlExecutionBlocked->value => 'Operational control blocked execution',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -69,6 +69,12 @@ class Capabilities
|
|||||||
|
|
||||||
public const TENANT_SYNC = 'tenant.sync';
|
public const TENANT_SYNC = 'tenant.sync';
|
||||||
|
|
||||||
|
// Support diagnostics
|
||||||
|
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';
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,8 @@ class PlatformCapabilities
|
|||||||
|
|
||||||
public const RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL = 'platform.runbooks.findings.lifecycle_backfill';
|
public const RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL = 'platform.runbooks.findings.lifecycle_backfill';
|
||||||
|
|
||||||
|
public const OPS_CONTROLS_MANAGE = 'platform.ops.controls.manage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string>
|
* @return array<string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\CustomerHealth;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class CustomerHealthDimensionCatalog
|
||||||
|
{
|
||||||
|
public const string ONBOARDING_READINESS = 'onboarding_readiness';
|
||||||
|
|
||||||
|
public const string PROVIDER_CONNECTION_HEALTH = 'provider_connection_health';
|
||||||
|
|
||||||
|
public const string OPERATIONAL_STABILITY = 'operational_stability';
|
||||||
|
|
||||||
|
public const string GOVERNANCE_PRESSURE = 'governance_pressure';
|
||||||
|
|
||||||
|
public const string REVIEW_PACK_READINESS = 'review_pack_readiness';
|
||||||
|
|
||||||
|
public const string ENGAGEMENT_FRESHNESS = 'engagement_freshness';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{label: string, windowed: bool}>
|
||||||
|
*/
|
||||||
|
private const DEFINITIONS = [
|
||||||
|
self::ONBOARDING_READINESS => [
|
||||||
|
'label' => 'Onboarding readiness',
|
||||||
|
'windowed' => false,
|
||||||
|
],
|
||||||
|
self::PROVIDER_CONNECTION_HEALTH => [
|
||||||
|
'label' => 'Provider connection health',
|
||||||
|
'windowed' => false,
|
||||||
|
],
|
||||||
|
self::OPERATIONAL_STABILITY => [
|
||||||
|
'label' => 'Operational stability',
|
||||||
|
'windowed' => true,
|
||||||
|
],
|
||||||
|
self::GOVERNANCE_PRESSURE => [
|
||||||
|
'label' => 'Governance pressure',
|
||||||
|
'windowed' => false,
|
||||||
|
],
|
||||||
|
self::REVIEW_PACK_READINESS => [
|
||||||
|
'label' => 'Review-pack readiness',
|
||||||
|
'windowed' => true,
|
||||||
|
],
|
||||||
|
self::ENGAGEMENT_FRESHNESS => [
|
||||||
|
'label' => 'Engagement freshness',
|
||||||
|
'windowed' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function names(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::DEFINITIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label: string, windowed: bool}
|
||||||
|
*/
|
||||||
|
public function definition(string $dimension): array
|
||||||
|
{
|
||||||
|
$definition = self::DEFINITIONS[$dimension] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($definition)) {
|
||||||
|
throw new InvalidArgumentException("Unknown customer health dimension [{$dimension}].");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(string $dimension): string
|
||||||
|
{
|
||||||
|
return $this->definition($dimension)['label'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isWindowed(string $dimension): bool
|
||||||
|
{
|
||||||
|
return $this->definition($dimension)['windowed'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function visibleDimensions(): array
|
||||||
|
{
|
||||||
|
$dimensions = [];
|
||||||
|
|
||||||
|
foreach ($this->names() as $dimension) {
|
||||||
|
$dimensions[$dimension] = $this->label($dimension);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dimensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $levels
|
||||||
|
*/
|
||||||
|
public function resolveOverallLevel(array $levels): string
|
||||||
|
{
|
||||||
|
foreach (['critical', 'warn', 'unknown'] as $level) {
|
||||||
|
if (in_array($level, $levels, true)) {
|
||||||
|
return $level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,766 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\CustomerHealth;
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\FindingException;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\ProductUsageEvent;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\Onboarding\OnboardingLifecycleState;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||||
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
|
use App\Support\System\SystemDirectoryLinks;
|
||||||
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
|
use App\Support\SystemConsole\StuckRunClassifier;
|
||||||
|
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
final class WorkspaceHealthSummaryQuery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CustomerHealthDimensionCatalog $dimensionCatalog,
|
||||||
|
private readonly StuckRunClassifier $stuckRunClassifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* workspace_name: string,
|
||||||
|
* overall_level: string,
|
||||||
|
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
||||||
|
* dominant_dimension_keys: list<string>,
|
||||||
|
* non_ok_dimension_count: int,
|
||||||
|
* next_link: array{label: string, url: string}
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function summaries(SystemConsoleWindow|string|null $window = null, ?CarbonImmutable $now = null): Collection
|
||||||
|
{
|
||||||
|
$resolvedWindow = $this->resolveWindow($window);
|
||||||
|
$now ??= CarbonImmutable::now();
|
||||||
|
$startAt = $resolvedWindow->startAt($now);
|
||||||
|
|
||||||
|
$workspaces = Workspace::query()
|
||||||
|
->whereNull('archived_at')
|
||||||
|
->orderBy('name')
|
||||||
|
->orderBy('id')
|
||||||
|
->get(['id', 'name']);
|
||||||
|
|
||||||
|
if ($workspaces->isEmpty()) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceIds = $workspaces
|
||||||
|
->pluck('id')
|
||||||
|
->map(static fn (mixed $workspaceId): int => (int) $workspaceId)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$activeTenants = Tenant::query()
|
||||||
|
->whereIn('workspace_id', $workspaceIds)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->where('status', '!=', Tenant::STATUS_ARCHIVED)
|
||||||
|
->orderBy('name')
|
||||||
|
->orderBy('id')
|
||||||
|
->get(['id', 'workspace_id', 'external_id', 'name', 'status']);
|
||||||
|
|
||||||
|
$tenantsByWorkspace = $activeTenants->groupBy(static fn (Tenant $tenant): int => (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$latestOnboardingSessions = TenantOnboardingSession::query()
|
||||||
|
->whereIn('workspace_id', $workspaceIds)
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$this->constrainToActiveTenantTruth($query);
|
||||||
|
})
|
||||||
|
->orderByDesc('updated_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get(['id', 'workspace_id', 'tenant_id', 'lifecycle_state', 'updated_at', 'created_at'])
|
||||||
|
->groupBy(static fn (TenantOnboardingSession $session): int => (int) $session->workspace_id)
|
||||||
|
->map(static fn (Collection $sessions): ?TenantOnboardingSession => $sessions->first());
|
||||||
|
|
||||||
|
$providerConnectionsByWorkspace = ProviderConnection::query()
|
||||||
|
->whereIn('workspace_id', $workspaceIds)
|
||||||
|
->where('is_default', true)
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$this->constrainToActiveTenantTruth($query);
|
||||||
|
})
|
||||||
|
->orderByDesc('is_enabled')
|
||||||
|
->orderBy('id')
|
||||||
|
->get([
|
||||||
|
'id',
|
||||||
|
'workspace_id',
|
||||||
|
'tenant_id',
|
||||||
|
'is_enabled',
|
||||||
|
'consent_status',
|
||||||
|
'verification_status',
|
||||||
|
])
|
||||||
|
->groupBy(static fn (ProviderConnection $connection): int => (int) $connection->workspace_id);
|
||||||
|
|
||||||
|
$recentRunCounts = $this->groupedCounts(
|
||||||
|
OperationRun::query()
|
||||||
|
->whereIn('workspace_id', $workspaceIds)
|
||||||
|
->where('created_at', '>=', $startAt)
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$this->constrainToActiveTenantTruth($query);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$recentFailedRunCounts = $this->groupedCounts(
|
||||||
|
OperationRun::query()
|
||||||
|
->whereIn('workspace_id', $workspaceIds)
|
||||||
|
->where('created_at', '>=', $startAt)
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where('outcome', OperationRunOutcome::Failed->value)
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$this->constrainToActiveTenantTruth($query);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$recentStuckRunCounts = $this->groupedCounts(
|
||||||
|
$this->stuckRunClassifier->apply(
|
||||||
|
OperationRun::query()
|
||||||
|
->whereIn('workspace_id', $workspaceIds)
|
||||||
|
->where('created_at', '>=', $startAt)
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$this->constrainToActiveTenantTruth($query);
|
||||||
|
}),
|
||||||
|
$now,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$activeHighSeverityFindingCounts = $this->groupedCounts(
|
||||||
|
Finding::query()
|
||||||
|
->whereIn('workspace_id', $workspaceIds)
|
||||||
|
->whereIn('severity', Finding::highSeverityValues())
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$this->constrainToActiveTenantTruth($query);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$anyGovernanceFindingCounts = $this->groupedCounts(
|
||||||
|
Finding::query()
|
||||||
|
->whereIn('workspace_id', $workspaceIds)
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$this->constrainToActiveTenantTruth($query);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$overdueHighSeverityFindingCounts = $this->groupedCounts(
|
||||||
|
Finding::query()
|
||||||
|
->whereIn('workspace_id', $workspaceIds)
|
||||||
|
->whereIn('severity', Finding::highSeverityValues())
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', $now)
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$this->constrainToActiveTenantTruth($query);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$warningExceptionCounts = $this->groupedCounts(
|
||||||
|
FindingException::query()
|
||||||
|
->whereIn('workspace_id', $workspaceIds)
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$query
|
||||||
|
->whereIn('status', [
|
||||||
|
FindingException::STATUS_PENDING,
|
||||||
|
FindingException::STATUS_EXPIRING,
|
||||||
|
])
|
||||||
|
->orWhere('current_validity_state', FindingException::VALIDITY_EXPIRING);
|
||||||
|
})
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$this->constrainToActiveTenantTruth($query);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$criticalExceptionCounts = $this->groupedCounts(
|
||||||
|
FindingException::query()
|
||||||
|
->whereIn('workspace_id', $workspaceIds)
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$query
|
||||||
|
->whereIn('status', [
|
||||||
|
FindingException::STATUS_EXPIRED,
|
||||||
|
FindingException::STATUS_REVOKED,
|
||||||
|
])
|
||||||
|
->orWhereIn('current_validity_state', [
|
||||||
|
FindingException::VALIDITY_EXPIRED,
|
||||||
|
FindingException::VALIDITY_REVOKED,
|
||||||
|
FindingException::VALIDITY_REJECTED,
|
||||||
|
FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$this->constrainToActiveTenantTruth($query);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$reviewPackRequestCounts = $this->groupedCounts(
|
||||||
|
ProductUsageEvent::query()
|
||||||
|
->whereIn('workspace_id', $workspaceIds)
|
||||||
|
->where('event_name', ProductUsageEventCatalog::REVIEW_PACK_REQUESTED)
|
||||||
|
->where('occurred_at', '>=', $startAt)
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$this->constrainToActiveTenantTruth($query);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$recentReviewPacks = ReviewPack::query()
|
||||||
|
->whereIn('workspace_id', $workspaceIds)
|
||||||
|
->where('created_at', '>=', $startAt)
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$this->constrainToActiveTenantTruth($query);
|
||||||
|
})
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get(['id', 'workspace_id', 'tenant_id', 'status', 'expires_at', 'created_at'])
|
||||||
|
->groupBy(static fn (ReviewPack $reviewPack): int => (int) $reviewPack->workspace_id)
|
||||||
|
->map(static fn (Collection $reviewPacks): ?ReviewPack => $reviewPacks->first());
|
||||||
|
|
||||||
|
$recentUsageEventCounts = $this->groupedCounts(
|
||||||
|
ProductUsageEvent::query()
|
||||||
|
->whereIn('workspace_id', $workspaceIds)
|
||||||
|
->where('occurred_at', '>=', $startAt)
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$this->constrainToActiveTenantTruth($query);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$historicalUsageEventCounts = $this->groupedCounts(
|
||||||
|
ProductUsageEvent::query()
|
||||||
|
->whereIn('workspace_id', $workspaceIds)
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$this->constrainToActiveTenantTruth($query);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return $workspaces
|
||||||
|
->map(function (Workspace $workspace) use (
|
||||||
|
$tenantsByWorkspace,
|
||||||
|
$latestOnboardingSessions,
|
||||||
|
$providerConnectionsByWorkspace,
|
||||||
|
$recentRunCounts,
|
||||||
|
$recentFailedRunCounts,
|
||||||
|
$recentStuckRunCounts,
|
||||||
|
$activeHighSeverityFindingCounts,
|
||||||
|
$anyGovernanceFindingCounts,
|
||||||
|
$overdueHighSeverityFindingCounts,
|
||||||
|
$warningExceptionCounts,
|
||||||
|
$criticalExceptionCounts,
|
||||||
|
$reviewPackRequestCounts,
|
||||||
|
$recentReviewPacks,
|
||||||
|
$recentUsageEventCounts,
|
||||||
|
$historicalUsageEventCounts,
|
||||||
|
$resolvedWindow,
|
||||||
|
$now,
|
||||||
|
): array {
|
||||||
|
$workspaceId = (int) $workspace->getKey();
|
||||||
|
/** @var Collection<int, Tenant> $workspaceTenants */
|
||||||
|
$workspaceTenants = $tenantsByWorkspace->get($workspaceId, collect());
|
||||||
|
/** @var TenantOnboardingSession|null $latestOnboardingSession */
|
||||||
|
$latestOnboardingSession = $latestOnboardingSessions->get($workspaceId);
|
||||||
|
/** @var Collection<int, ProviderConnection> $providerConnections */
|
||||||
|
$providerConnections = $providerConnectionsByWorkspace->get($workspaceId, collect());
|
||||||
|
/** @var ReviewPack|null $latestReviewPack */
|
||||||
|
$latestReviewPack = $recentReviewPacks->get($workspaceId);
|
||||||
|
|
||||||
|
$dimensions = $this->buildDimensions(
|
||||||
|
tenants: $workspaceTenants,
|
||||||
|
latestOnboardingSession: $latestOnboardingSession,
|
||||||
|
providerConnections: $providerConnections,
|
||||||
|
recentRunCount: $this->countForWorkspace($recentRunCounts, $workspaceId),
|
||||||
|
recentFailedRunCount: $this->countForWorkspace($recentFailedRunCounts, $workspaceId),
|
||||||
|
recentStuckRunCount: $this->countForWorkspace($recentStuckRunCounts, $workspaceId),
|
||||||
|
activeHighSeverityFindingCount: $this->countForWorkspace($activeHighSeverityFindingCounts, $workspaceId),
|
||||||
|
anyGovernanceFindingCount: $this->countForWorkspace($anyGovernanceFindingCounts, $workspaceId),
|
||||||
|
overdueHighSeverityFindingCount: $this->countForWorkspace($overdueHighSeverityFindingCounts, $workspaceId),
|
||||||
|
warningExceptionCount: $this->countForWorkspace($warningExceptionCounts, $workspaceId),
|
||||||
|
criticalExceptionCount: $this->countForWorkspace($criticalExceptionCounts, $workspaceId),
|
||||||
|
reviewPackRequestCount: $this->countForWorkspace($reviewPackRequestCounts, $workspaceId),
|
||||||
|
latestReviewPack: $latestReviewPack,
|
||||||
|
recentUsageEventCount: $this->countForWorkspace($recentUsageEventCounts, $workspaceId),
|
||||||
|
historicalUsageEventCount: $this->countForWorkspace($historicalUsageEventCounts, $workspaceId),
|
||||||
|
now: $now,
|
||||||
|
);
|
||||||
|
|
||||||
|
$overallLevel = $this->dimensionCatalog->resolveOverallLevel(
|
||||||
|
array_map(static fn (array $dimension): string => $dimension['level'], $dimensions),
|
||||||
|
);
|
||||||
|
|
||||||
|
$dominantDimensionKeys = $this->dominantDimensionKeys($dimensions);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'workspace_name' => (string) $workspace->name,
|
||||||
|
'overall_level' => $overallLevel,
|
||||||
|
'dimensions' => $dimensions,
|
||||||
|
'dominant_dimension_keys' => $dominantDimensionKeys,
|
||||||
|
'non_ok_dimension_count' => count(array_filter(
|
||||||
|
$dimensions,
|
||||||
|
static fn (array $dimension): bool => $dimension['level'] !== 'ok',
|
||||||
|
)),
|
||||||
|
'next_link' => $this->nextLink(
|
||||||
|
workspace: $workspace,
|
||||||
|
tenants: $workspaceTenants,
|
||||||
|
dominantDimensionKeys: $dominantDimensionKeys,
|
||||||
|
window: $resolvedWindow,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* workspace_name: string,
|
||||||
|
* overall_level: string,
|
||||||
|
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
||||||
|
* dominant_dimension_keys: list<string>,
|
||||||
|
* non_ok_dimension_count: int,
|
||||||
|
* next_link: array{label: string, url: string}
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public function summaryForWorkspace(Workspace|int $workspace, SystemConsoleWindow|string|null $window = null, ?CarbonImmutable $now = null): ?array
|
||||||
|
{
|
||||||
|
$workspaceId = $workspace instanceof Workspace ? (int) $workspace->getKey() : (int) $workspace;
|
||||||
|
|
||||||
|
/** @var array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* workspace_name: string,
|
||||||
|
* overall_level: string,
|
||||||
|
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
||||||
|
* dominant_dimension_keys: list<string>,
|
||||||
|
* non_ok_dimension_count: int,
|
||||||
|
* next_link: array{label: string, url: string}
|
||||||
|
* }|null $summary
|
||||||
|
*/
|
||||||
|
$summary = $this->summaries($window, $now)->firstWhere('workspace_id', $workspaceId);
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok: int, warn: int, critical: int, unknown: int}
|
||||||
|
*/
|
||||||
|
public function healthCounts(SystemConsoleWindow|string|null $window = null, ?CarbonImmutable $now = null): array
|
||||||
|
{
|
||||||
|
$counts = [
|
||||||
|
'ok' => 0,
|
||||||
|
'warn' => 0,
|
||||||
|
'critical' => 0,
|
||||||
|
'unknown' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($this->summaries($window, $now) as $summary) {
|
||||||
|
$counts[$summary['overall_level']]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* workspace_name: string,
|
||||||
|
* overall_level: string,
|
||||||
|
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
||||||
|
* dominant_dimension_keys: list<string>,
|
||||||
|
* non_ok_dimension_count: int,
|
||||||
|
* next_link: array{label: string, url: string}
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function attentionNeeded(SystemConsoleWindow|string|null $window = null, int $limit = 10, ?CarbonImmutable $now = null): Collection
|
||||||
|
{
|
||||||
|
return $this->summaries($window, $now)
|
||||||
|
->filter(static fn (array $summary): bool => $summary['overall_level'] !== 'ok')
|
||||||
|
->sort(function (array $left, array $right): int {
|
||||||
|
$severityComparison = $this->levelRank($right['overall_level']) <=> $this->levelRank($left['overall_level']);
|
||||||
|
|
||||||
|
if ($severityComparison !== 0) {
|
||||||
|
return $severityComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nonOkComparison = $right['non_ok_dimension_count'] <=> $left['non_ok_dimension_count'];
|
||||||
|
|
||||||
|
if ($nonOkComparison !== 0) {
|
||||||
|
return $nonOkComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nameComparison = strcasecmp($left['workspace_name'], $right['workspace_name']);
|
||||||
|
|
||||||
|
if ($nameComparison !== 0) {
|
||||||
|
return $nameComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $left['workspace_id'] <=> $right['workspace_id'];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->take(max(1, $limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveWindow(SystemConsoleWindow|string|null $window): SystemConsoleWindow
|
||||||
|
{
|
||||||
|
if ($window instanceof SystemConsoleWindow) {
|
||||||
|
return $window;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SystemConsoleWindow::fromNullable(is_string($window) ? $window : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Tenant> $tenants
|
||||||
|
* @param Collection<int, ProviderConnection> $providerConnections
|
||||||
|
* @return array<string, array{label: string, level: string, windowed: bool}>
|
||||||
|
*/
|
||||||
|
private function buildDimensions(
|
||||||
|
Collection $tenants,
|
||||||
|
?TenantOnboardingSession $latestOnboardingSession,
|
||||||
|
Collection $providerConnections,
|
||||||
|
int $recentRunCount,
|
||||||
|
int $recentFailedRunCount,
|
||||||
|
int $recentStuckRunCount,
|
||||||
|
int $activeHighSeverityFindingCount,
|
||||||
|
int $anyGovernanceFindingCount,
|
||||||
|
int $overdueHighSeverityFindingCount,
|
||||||
|
int $warningExceptionCount,
|
||||||
|
int $criticalExceptionCount,
|
||||||
|
int $reviewPackRequestCount,
|
||||||
|
?ReviewPack $latestReviewPack,
|
||||||
|
int $recentUsageEventCount,
|
||||||
|
int $historicalUsageEventCount,
|
||||||
|
CarbonImmutable $now,
|
||||||
|
): array {
|
||||||
|
$levels = [
|
||||||
|
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $this->onboardingReadinessLevel($tenants, $latestOnboardingSession),
|
||||||
|
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $this->providerConnectionHealthLevel($providerConnections),
|
||||||
|
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => $this->operationalStabilityLevel($recentRunCount, $recentFailedRunCount, $recentStuckRunCount),
|
||||||
|
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $this->governancePressureLevel(
|
||||||
|
activeHighSeverityFindingCount: $activeHighSeverityFindingCount,
|
||||||
|
anyGovernanceFindingCount: $anyGovernanceFindingCount,
|
||||||
|
overdueHighSeverityFindingCount: $overdueHighSeverityFindingCount,
|
||||||
|
warningExceptionCount: $warningExceptionCount,
|
||||||
|
criticalExceptionCount: $criticalExceptionCount,
|
||||||
|
),
|
||||||
|
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => $this->reviewPackReadinessLevel($reviewPackRequestCount, $latestReviewPack, $now),
|
||||||
|
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $this->engagementFreshnessLevel($recentUsageEventCount, $historicalUsageEventCount),
|
||||||
|
];
|
||||||
|
|
||||||
|
$dimensions = [];
|
||||||
|
|
||||||
|
foreach ($this->dimensionCatalog->visibleDimensions() as $dimensionKey => $label) {
|
||||||
|
$dimensions[$dimensionKey] = [
|
||||||
|
'label' => $label,
|
||||||
|
'level' => $levels[$dimensionKey],
|
||||||
|
'windowed' => $this->dimensionCatalog->isWindowed($dimensionKey),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dimensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Tenant> $tenants
|
||||||
|
*/
|
||||||
|
private function onboardingReadinessLevel(Collection $tenants, ?TenantOnboardingSession $latestOnboardingSession): string
|
||||||
|
{
|
||||||
|
if ($latestOnboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
return match ($latestOnboardingSession->lifecycleState()) {
|
||||||
|
OnboardingLifecycleState::ReadyForActivation,
|
||||||
|
OnboardingLifecycleState::Completed => 'ok',
|
||||||
|
OnboardingLifecycleState::Cancelled => 'critical',
|
||||||
|
OnboardingLifecycleState::Draft,
|
||||||
|
OnboardingLifecycleState::Verifying,
|
||||||
|
OnboardingLifecycleState::ActionRequired,
|
||||||
|
OnboardingLifecycleState::Bootstrapping => 'warn',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenants->isEmpty()) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenants->contains(static fn (Tenant $tenant): bool => (string) $tenant->status === Tenant::STATUS_ACTIVE)) {
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, ProviderConnection> $providerConnections
|
||||||
|
*/
|
||||||
|
private function providerConnectionHealthLevel(Collection $providerConnections): string
|
||||||
|
{
|
||||||
|
if ($providerConnections->isEmpty()) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($providerConnections->contains(function (ProviderConnection $connection): bool {
|
||||||
|
if (! $connection->is_enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$consentStatus = $this->normalizeBackedEnumValue($connection->consent_status);
|
||||||
|
$verificationStatus = $this->normalizeBackedEnumValue($connection->verification_status);
|
||||||
|
|
||||||
|
return in_array($consentStatus, [
|
||||||
|
ProviderConsentStatus::Required->value,
|
||||||
|
ProviderConsentStatus::Failed->value,
|
||||||
|
ProviderConsentStatus::Revoked->value,
|
||||||
|
], true) || in_array($verificationStatus, [
|
||||||
|
ProviderVerificationStatus::Blocked->value,
|
||||||
|
ProviderVerificationStatus::Error->value,
|
||||||
|
], true);
|
||||||
|
})) {
|
||||||
|
return 'critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($providerConnections->contains(function (ProviderConnection $connection): bool {
|
||||||
|
if (! $connection->is_enabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$consentStatus = $this->normalizeBackedEnumValue($connection->consent_status);
|
||||||
|
$verificationStatus = $this->normalizeBackedEnumValue($connection->verification_status);
|
||||||
|
|
||||||
|
return in_array($consentStatus, [
|
||||||
|
ProviderConsentStatus::Unknown->value,
|
||||||
|
], true) || in_array($verificationStatus, [
|
||||||
|
ProviderVerificationStatus::Unknown->value,
|
||||||
|
ProviderVerificationStatus::Pending->value,
|
||||||
|
ProviderVerificationStatus::Degraded->value,
|
||||||
|
], true);
|
||||||
|
})) {
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($providerConnections->contains(function (ProviderConnection $connection): bool {
|
||||||
|
return $connection->is_enabled
|
||||||
|
&& $this->normalizeBackedEnumValue($connection->consent_status) === ProviderConsentStatus::Granted->value
|
||||||
|
&& $this->normalizeBackedEnumValue($connection->verification_status) === ProviderVerificationStatus::Healthy->value;
|
||||||
|
})) {
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function operationalStabilityLevel(int $recentRunCount, int $recentFailedRunCount, int $recentStuckRunCount): string
|
||||||
|
{
|
||||||
|
if ($recentFailedRunCount > 0 || $recentStuckRunCount > 0) {
|
||||||
|
return 'critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($recentRunCount > 0) {
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function governancePressureLevel(
|
||||||
|
int $activeHighSeverityFindingCount,
|
||||||
|
int $anyGovernanceFindingCount,
|
||||||
|
int $overdueHighSeverityFindingCount,
|
||||||
|
int $warningExceptionCount,
|
||||||
|
int $criticalExceptionCount,
|
||||||
|
): string {
|
||||||
|
if ($overdueHighSeverityFindingCount > 0 || $criticalExceptionCount > 0) {
|
||||||
|
return 'critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($activeHighSeverityFindingCount > 0 || $warningExceptionCount > 0) {
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($anyGovernanceFindingCount === 0 && $warningExceptionCount === 0 && $criticalExceptionCount === 0) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewPackReadinessLevel(int $reviewPackRequestCount, ?ReviewPack $latestReviewPack, CarbonImmutable $now): string
|
||||||
|
{
|
||||||
|
if ($reviewPackRequestCount === 0 && ! $latestReviewPack instanceof ReviewPack) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $latestReviewPack instanceof ReviewPack) {
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(string) $latestReviewPack->status === ReviewPack::STATUS_READY
|
||||||
|
&& (! $latestReviewPack->expires_at instanceof CarbonImmutable || $latestReviewPack->expires_at->gt($now))
|
||||||
|
) {
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
in_array((string) $latestReviewPack->status, [
|
||||||
|
ReviewPack::STATUS_FAILED,
|
||||||
|
ReviewPack::STATUS_EXPIRED,
|
||||||
|
], true)
|
||||||
|
|| ($latestReviewPack->expires_at !== null && $latestReviewPack->expires_at->lte($now))
|
||||||
|
) {
|
||||||
|
return 'critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function engagementFreshnessLevel(int $recentUsageEventCount, int $historicalUsageEventCount): string
|
||||||
|
{
|
||||||
|
if ($recentUsageEventCount > 0) {
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($historicalUsageEventCount > 0) {
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array{label: string, level: string, windowed: bool}> $dimensions
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function dominantDimensionKeys(array $dimensions): array
|
||||||
|
{
|
||||||
|
$catalogOrder = array_flip($this->dimensionCatalog->names());
|
||||||
|
|
||||||
|
return collect($dimensions)
|
||||||
|
->reject(static fn (array $dimension): bool => $dimension['level'] === 'ok')
|
||||||
|
->keys()
|
||||||
|
->sort(function (string $left, string $right) use ($dimensions, $catalogOrder): int {
|
||||||
|
$severityComparison = $this->levelRank($dimensions[$right]['level']) <=> $this->levelRank($dimensions[$left]['level']);
|
||||||
|
|
||||||
|
if ($severityComparison !== 0) {
|
||||||
|
return $severityComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($catalogOrder[$left] ?? PHP_INT_MAX) <=> ($catalogOrder[$right] ?? PHP_INT_MAX);
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Tenant> $tenants
|
||||||
|
* @param list<string> $dominantDimensionKeys
|
||||||
|
* @return array{label: string, url: string}
|
||||||
|
*/
|
||||||
|
private function nextLink(Workspace $workspace, Collection $tenants, array $dominantDimensionKeys, SystemConsoleWindow $window): array
|
||||||
|
{
|
||||||
|
$dominantDimension = $dominantDimensionKeys[0] ?? null;
|
||||||
|
|
||||||
|
if ($dominantDimension === CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY && $this->canOpenRunsLink()) {
|
||||||
|
return [
|
||||||
|
'label' => 'Open runs',
|
||||||
|
'url' => SystemOperationRunLinks::index(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Tenant|null $tenant */
|
||||||
|
$tenant = $tenants->first();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
'label' => 'Review health details',
|
||||||
|
'url' => $this->withWindowQuery(SystemDirectoryLinks::tenantDetail($tenant), $window),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => 'Review health details',
|
||||||
|
'url' => $this->withWindowQuery(SystemDirectoryLinks::workspaceDetail($workspace), $window),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function withWindowQuery(string $url, SystemConsoleWindow $window): string
|
||||||
|
{
|
||||||
|
$separator = str_contains($url, '?') ? '&' : '?';
|
||||||
|
|
||||||
|
return $url.$separator.http_build_query(['window' => $window->value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canOpenRunsLink(): bool
|
||||||
|
{
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $user instanceof PlatformUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
||||||
|
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<OperationRun>|Builder<ProductUsageEvent>|Builder<ReviewPack>|Builder<Finding>|Builder<FindingException> $query
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function groupedCounts(Builder $query): array
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->selectRaw('workspace_id, COUNT(*) as aggregate')
|
||||||
|
->groupBy('workspace_id')
|
||||||
|
->pluck('aggregate', 'workspace_id')
|
||||||
|
->mapWithKeys(static fn (mixed $count, mixed $workspaceId): array => [
|
||||||
|
(int) $workspaceId => (int) $count,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $counts
|
||||||
|
*/
|
||||||
|
private function countForWorkspace(array $counts, int $workspaceId): int
|
||||||
|
{
|
||||||
|
return (int) ($counts[$workspaceId] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function levelRank(string $level): int
|
||||||
|
{
|
||||||
|
return match ($level) {
|
||||||
|
'critical' => 4,
|
||||||
|
'warn' => 3,
|
||||||
|
'unknown' => 2,
|
||||||
|
default => 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeBackedEnumValue(mixed $value): string
|
||||||
|
{
|
||||||
|
if (is_object($value) && property_exists($value, 'value')) {
|
||||||
|
return (string) $value->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function constrainToActiveTenantTruth(Builder $query): void
|
||||||
|
{
|
||||||
|
$query
|
||||||
|
->whereNull('tenant_id')
|
||||||
|
->orWhereHas('tenant', function (Builder $tenantQuery): void {
|
||||||
|
$tenantQuery
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->where('status', '!=', Tenant::STATUS_ARCHIVED);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\OperationalControls;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class OperationalControlBlockedException extends RuntimeException
|
||||||
|
{
|
||||||
|
private function __construct(
|
||||||
|
public readonly OperationalControlDecision $decision,
|
||||||
|
public readonly string $actionLabel,
|
||||||
|
) {
|
||||||
|
$message = trim($decision->reasonText ?? '');
|
||||||
|
|
||||||
|
parent::__construct($message !== ''
|
||||||
|
? sprintf('%s is currently paused. %s', $actionLabel, $message)
|
||||||
|
: sprintf('%s is currently paused.', $actionLabel));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forDecision(OperationalControlDecision $decision, string $actionLabel): self
|
||||||
|
{
|
||||||
|
return new self($decision, $actionLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function title(): string
|
||||||
|
{
|
||||||
|
return sprintf('%s paused', $this->actionLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\OperationalControls;
|
||||||
|
|
||||||
|
final class OperationalControlCatalog
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, array{key: string, label: string, supported_scopes: array<int, string>, operation_types: array<int, string>, affected_surfaces: array<int, string>}>
|
||||||
|
*/
|
||||||
|
private const DEFINITIONS = [
|
||||||
|
'restore.execute' => [
|
||||||
|
'key' => 'restore.execute',
|
||||||
|
'label' => 'Restore execution',
|
||||||
|
'supported_scopes' => ['global', 'workspace'],
|
||||||
|
'operation_types' => ['restore.execute'],
|
||||||
|
'affected_surfaces' => ['tenant.restore_runs.create'],
|
||||||
|
],
|
||||||
|
'ai.execution' => [
|
||||||
|
'key' => 'ai.execution',
|
||||||
|
'label' => 'AI execution',
|
||||||
|
'supported_scopes' => ['global'],
|
||||||
|
'operation_types' => ['ai.execution'],
|
||||||
|
'affected_surfaces' => ['governed_ai.execution'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function keys(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::DEFINITIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function definitions(): array
|
||||||
|
{
|
||||||
|
return self::DEFINITIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{key: string, label: string, supported_scopes: array<int, string>, operation_types: array<int, string>, affected_surfaces: array<int, string>}
|
||||||
|
*/
|
||||||
|
public function definition(string $controlKey): array
|
||||||
|
{
|
||||||
|
$controlKey = trim($controlKey);
|
||||||
|
|
||||||
|
if (! array_key_exists($controlKey, self::DEFINITIONS)) {
|
||||||
|
throw new \InvalidArgumentException("Unknown operational control [{$controlKey}].");
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::DEFINITIONS[$controlKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(string $controlKey): string
|
||||||
|
{
|
||||||
|
return $this->definition($controlKey)['label'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\OperationalControls;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
final class OperationalControlDecision
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $controlKey,
|
||||||
|
public readonly string $effectiveState,
|
||||||
|
public readonly string $matchedScopeType,
|
||||||
|
public readonly ?int $workspaceId,
|
||||||
|
public readonly ?string $reasonText,
|
||||||
|
public readonly ?CarbonInterface $expiresAt,
|
||||||
|
public readonly ?int $sourceActivationId,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function enabled(string $controlKey): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
controlKey: $controlKey,
|
||||||
|
effectiveState: 'enabled',
|
||||||
|
matchedScopeType: 'none',
|
||||||
|
workspaceId: null,
|
||||||
|
reasonText: null,
|
||||||
|
expiresAt: null,
|
||||||
|
sourceActivationId: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function paused(
|
||||||
|
string $controlKey,
|
||||||
|
string $matchedScopeType,
|
||||||
|
?int $workspaceId,
|
||||||
|
?string $reasonText,
|
||||||
|
?CarbonInterface $expiresAt,
|
||||||
|
?int $sourceActivationId,
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
controlKey: $controlKey,
|
||||||
|
effectiveState: 'paused',
|
||||||
|
matchedScopeType: $matchedScopeType,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
reasonText: $reasonText,
|
||||||
|
expiresAt: $expiresAt,
|
||||||
|
sourceActivationId: $sourceActivationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->effectiveState === 'enabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPaused(): bool
|
||||||
|
{
|
||||||
|
return $this->effectiveState === 'paused';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasWorkspaceScope(): bool
|
||||||
|
{
|
||||||
|
return $this->matchedScopeType === 'workspace' && $this->workspaceId !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this->matchedScopeType) {
|
||||||
|
'global' => 'Global',
|
||||||
|
'workspace' => $this->workspaceId !== null ? 'Workspace #'.$this->workspaceId : 'Workspace',
|
||||||
|
default => 'No active pause',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expiresAtIso8601(): ?string
|
||||||
|
{
|
||||||
|
return $this->expiresAt?->toIso8601String();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\OperationalControls;
|
||||||
|
|
||||||
|
use App\Models\OperationalControlActivation;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
|
||||||
|
final class OperationalControlEvaluator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OperationalControlCatalog $catalog,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function evaluate(string $controlKey, Workspace|int|null $workspace = null): OperationalControlDecision
|
||||||
|
{
|
||||||
|
$definition = $this->catalog->definition($controlKey);
|
||||||
|
$workspaceId = $workspace instanceof Workspace
|
||||||
|
? (int) $workspace->getKey()
|
||||||
|
: (is_int($workspace) ? $workspace : null);
|
||||||
|
|
||||||
|
$globalActivation = OperationalControlActivation::query()
|
||||||
|
->forControl($definition['key'])
|
||||||
|
->forGlobalScope()
|
||||||
|
->notExpired()
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($globalActivation instanceof OperationalControlActivation) {
|
||||||
|
return OperationalControlDecision::paused(
|
||||||
|
controlKey: $definition['key'],
|
||||||
|
matchedScopeType: 'global',
|
||||||
|
workspaceId: null,
|
||||||
|
reasonText: $globalActivation->reason_text,
|
||||||
|
expiresAt: $globalActivation->expires_at,
|
||||||
|
sourceActivationId: (int) $globalActivation->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($workspaceId !== null) {
|
||||||
|
$workspaceActivation = OperationalControlActivation::query()
|
||||||
|
->forControl($definition['key'])
|
||||||
|
->forWorkspaceScope($workspaceId)
|
||||||
|
->notExpired()
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($workspaceActivation instanceof OperationalControlActivation) {
|
||||||
|
return OperationalControlDecision::paused(
|
||||||
|
controlKey: $definition['key'],
|
||||||
|
matchedScopeType: 'workspace',
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
reasonText: $workspaceActivation->reason_text,
|
||||||
|
expiresAt: $workspaceActivation->expires_at,
|
||||||
|
sourceActivationId: (int) $workspaceActivation->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationalControlDecision::enabled($definition['key']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,263 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\ProductKnowledge;
|
||||||
|
|
||||||
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class ContextualHelpCatalog
|
||||||
|
{
|
||||||
|
public const string ADMIN_CONSENT_REQUIRED = 'admin-consent-required';
|
||||||
|
|
||||||
|
public const string REQUIRED_PERMISSIONS_MISSING = 'required-permissions-missing';
|
||||||
|
|
||||||
|
public const string CONNECTION_UNHEALTHY = 'connection-unhealthy';
|
||||||
|
|
||||||
|
public const string VERIFICATION_STALE = 'verification-stale';
|
||||||
|
|
||||||
|
public const string VERIFICATION_FAILED = 'verification-failed';
|
||||||
|
|
||||||
|
public const string DIAGNOSTIC_EVIDENCE_INCOMPLETE = 'diagnostic-evidence-incomplete';
|
||||||
|
|
||||||
|
public const string RETRYABLE_PROVIDER_FAILURE = 'retryable-provider-failure';
|
||||||
|
|
||||||
|
public const string MANUAL_HANDOFF_REQUIRED = 'manual-handoff-required';
|
||||||
|
|
||||||
|
public const string LINK_RESOLVER_ADMIN_CONSENT_PRIMARY = 'admin_consent_primary';
|
||||||
|
|
||||||
|
public const string LINK_RESOLVER_REQUIRED_PERMISSIONS = 'required_permissions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function keys(): array
|
||||||
|
{
|
||||||
|
return array_keys($this->definitions());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{
|
||||||
|
* topic_key: string,
|
||||||
|
* surface_families: list<string>,
|
||||||
|
* headline: string,
|
||||||
|
* short_explanation: string,
|
||||||
|
* troubleshooting_steps: list<string>,
|
||||||
|
* safe_next_action: string,
|
||||||
|
* glossary_terms: list<string>,
|
||||||
|
* docs_links: list<array{
|
||||||
|
* label: string,
|
||||||
|
* kind: string,
|
||||||
|
* url?: string,
|
||||||
|
* resolver?: string
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function definitions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::ADMIN_CONSENT_REQUIRED => [
|
||||||
|
'topic_key' => self::ADMIN_CONSENT_REQUIRED,
|
||||||
|
'surface_families' => ['onboarding', 'support_diagnostics'],
|
||||||
|
'headline' => 'Admin consent required',
|
||||||
|
'short_explanation' => 'This workflow is blocked until admin consent is granted for the current provider connection.',
|
||||||
|
'troubleshooting_steps' => [
|
||||||
|
'Grant admin consent for the current provider connection.',
|
||||||
|
'Re-run verification or reopen support diagnostics after consent completes.',
|
||||||
|
],
|
||||||
|
'safe_next_action' => 'Grant admin consent and re-run verification.',
|
||||||
|
'glossary_terms' => ['tenant', 'workspace', 'provider connection'],
|
||||||
|
'docs_links' => [
|
||||||
|
[
|
||||||
|
'label' => 'Grant admin consent',
|
||||||
|
'kind' => 'action',
|
||||||
|
'resolver' => self::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Admin consent guide',
|
||||||
|
'kind' => 'docs',
|
||||||
|
'url' => RequiredPermissionsLinks::adminConsentGuideUrl(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
self::REQUIRED_PERMISSIONS_MISSING => [
|
||||||
|
'topic_key' => self::REQUIRED_PERMISSIONS_MISSING,
|
||||||
|
'surface_families' => ['onboarding', 'support_diagnostics'],
|
||||||
|
'headline' => 'Required permissions missing',
|
||||||
|
'short_explanation' => 'The provider app is missing one or more required permissions for the current workflow.',
|
||||||
|
'troubleshooting_steps' => [
|
||||||
|
'Review the required permissions matrix for the current tenant.',
|
||||||
|
'Refresh verification after the missing permissions are granted.',
|
||||||
|
],
|
||||||
|
'safe_next_action' => 'Open required permissions and confirm the missing grants.',
|
||||||
|
'glossary_terms' => ['tenant', 'workspace', 'provider connection'],
|
||||||
|
'docs_links' => [
|
||||||
|
[
|
||||||
|
'label' => 'Open required permissions',
|
||||||
|
'kind' => 'action',
|
||||||
|
'resolver' => self::LINK_RESOLVER_REQUIRED_PERMISSIONS,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
self::CONNECTION_UNHEALTHY => [
|
||||||
|
'topic_key' => self::CONNECTION_UNHEALTHY,
|
||||||
|
'surface_families' => ['onboarding', 'support_diagnostics'],
|
||||||
|
'headline' => 'Provider connection needs review',
|
||||||
|
'short_explanation' => 'The provider connection is degraded or unavailable, so the current result cannot be treated as healthy.',
|
||||||
|
'troubleshooting_steps' => [
|
||||||
|
'Review the latest provider connection health signal.',
|
||||||
|
'Retry the workflow after the provider connection is healthy again.',
|
||||||
|
],
|
||||||
|
'safe_next_action' => 'Review the provider connection before retrying.',
|
||||||
|
'glossary_terms' => ['tenant', 'workspace', 'provider connection'],
|
||||||
|
'docs_links' => [],
|
||||||
|
],
|
||||||
|
self::VERIFICATION_STALE => [
|
||||||
|
'topic_key' => self::VERIFICATION_STALE,
|
||||||
|
'surface_families' => ['onboarding'],
|
||||||
|
'headline' => 'Verification result is stale',
|
||||||
|
'short_explanation' => 'The most recent verification result is too old or mismatched to trust for the current onboarding decision.',
|
||||||
|
'troubleshooting_steps' => [
|
||||||
|
'Run verification again for the currently selected provider connection.',
|
||||||
|
'Use the refreshed result before continuing onboarding.',
|
||||||
|
],
|
||||||
|
'safe_next_action' => 'Refresh verification before continuing onboarding.',
|
||||||
|
'glossary_terms' => ['tenant', 'workspace', 'verification'],
|
||||||
|
'docs_links' => [],
|
||||||
|
],
|
||||||
|
self::VERIFICATION_FAILED => [
|
||||||
|
'topic_key' => self::VERIFICATION_FAILED,
|
||||||
|
'surface_families' => ['onboarding', 'support_diagnostics'],
|
||||||
|
'headline' => 'Verification failed',
|
||||||
|
'short_explanation' => 'The latest verification run did not produce a decision-grade result for the current tenant context.',
|
||||||
|
'troubleshooting_steps' => [
|
||||||
|
'Review the blocking reason before retrying verification.',
|
||||||
|
'Confirm the prerequisite is fixed, then run verification again.',
|
||||||
|
],
|
||||||
|
'safe_next_action' => 'Review the blocking reason and retry verification.',
|
||||||
|
'glossary_terms' => ['tenant', 'workspace', 'verification'],
|
||||||
|
'docs_links' => [],
|
||||||
|
],
|
||||||
|
self::DIAGNOSTIC_EVIDENCE_INCOMPLETE => [
|
||||||
|
'topic_key' => self::DIAGNOSTIC_EVIDENCE_INCOMPLETE,
|
||||||
|
'surface_families' => ['support_diagnostics'],
|
||||||
|
'headline' => 'Diagnostic evidence is incomplete',
|
||||||
|
'short_explanation' => 'Support diagnostics can summarize the issue, but the available evidence is not complete enough for a final conclusion.',
|
||||||
|
'troubleshooting_steps' => [
|
||||||
|
'Review the available evidence and supporting references in the current support context.',
|
||||||
|
'Collect a fresh verification or operation result before making a final decision.',
|
||||||
|
],
|
||||||
|
'safe_next_action' => 'Collect a fresher or more complete diagnostic signal.',
|
||||||
|
'glossary_terms' => ['support diagnostics', 'evidence', 'workspace'],
|
||||||
|
'docs_links' => [],
|
||||||
|
],
|
||||||
|
self::RETRYABLE_PROVIDER_FAILURE => [
|
||||||
|
'topic_key' => self::RETRYABLE_PROVIDER_FAILURE,
|
||||||
|
'surface_families' => ['support_diagnostics'],
|
||||||
|
'headline' => 'Provider failure looks retryable',
|
||||||
|
'short_explanation' => 'The current provider issue appears temporary, so the next safe step is to retry once the dependency recovers.',
|
||||||
|
'troubleshooting_steps' => [
|
||||||
|
'Confirm the provider dependency has recovered.',
|
||||||
|
'Retry the workflow after the provider-side issue clears.',
|
||||||
|
],
|
||||||
|
'safe_next_action' => 'Retry after the provider dependency recovers.',
|
||||||
|
'glossary_terms' => ['support diagnostics', 'provider connection', 'workspace'],
|
||||||
|
'docs_links' => [],
|
||||||
|
],
|
||||||
|
self::MANUAL_HANDOFF_REQUIRED => [
|
||||||
|
'topic_key' => self::MANUAL_HANDOFF_REQUIRED,
|
||||||
|
'surface_families' => ['support_diagnostics'],
|
||||||
|
'headline' => 'Manual support handoff required',
|
||||||
|
'short_explanation' => 'TenantPilot can summarize the current issue, but a human support handoff is still required for the next step.',
|
||||||
|
'troubleshooting_steps' => [
|
||||||
|
'Review the current references before handing off the case.',
|
||||||
|
'Capture the safe next step and the supporting evidence for the receiving operator.',
|
||||||
|
],
|
||||||
|
'safe_next_action' => 'Hand off the case with the current diagnostic summary and supporting references.',
|
||||||
|
'glossary_terms' => ['support diagnostics', 'tenant', 'workspace'],
|
||||||
|
'docs_links' => [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* topic_key: string,
|
||||||
|
* surface_families: list<string>,
|
||||||
|
* headline: string,
|
||||||
|
* short_explanation: string,
|
||||||
|
* troubleshooting_steps: list<string>,
|
||||||
|
* safe_next_action: string,
|
||||||
|
* glossary_terms: list<string>,
|
||||||
|
* docs_links: list<array{
|
||||||
|
* label: string,
|
||||||
|
* kind: string,
|
||||||
|
* url?: string,
|
||||||
|
* resolver?: string
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function definition(string $topicKey): array
|
||||||
|
{
|
||||||
|
$definition = $this->definitions()[trim($topicKey)] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($definition)) {
|
||||||
|
throw new InvalidArgumentException('Unknown contextual help topic');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* version: int,
|
||||||
|
* topic_count: int,
|
||||||
|
* topics: list<array{
|
||||||
|
* topic_key: string,
|
||||||
|
* surface_families: list<string>,
|
||||||
|
* headline: string,
|
||||||
|
* short_explanation: string,
|
||||||
|
* troubleshooting_steps: list<string>,
|
||||||
|
* safe_next_action: string,
|
||||||
|
* glossary_terms: list<string>,
|
||||||
|
* docs_links: list<array{
|
||||||
|
* label: string,
|
||||||
|
* kind: string,
|
||||||
|
* url: ?string,
|
||||||
|
* resolver: ?string
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function knowledgeSource(): array
|
||||||
|
{
|
||||||
|
$topics = array_values(array_map(
|
||||||
|
fn (array $definition): array => [
|
||||||
|
'topic_key' => $definition['topic_key'],
|
||||||
|
'surface_families' => $definition['surface_families'],
|
||||||
|
'headline' => $definition['headline'],
|
||||||
|
'short_explanation' => $definition['short_explanation'],
|
||||||
|
'troubleshooting_steps' => $definition['troubleshooting_steps'],
|
||||||
|
'safe_next_action' => $definition['safe_next_action'],
|
||||||
|
'glossary_terms' => $definition['glossary_terms'],
|
||||||
|
'docs_links' => array_values(array_map(
|
||||||
|
static fn (array $link): array => [
|
||||||
|
'label' => (string) $link['label'],
|
||||||
|
'kind' => (string) ($link['kind'] ?? 'url'),
|
||||||
|
'url' => isset($link['url']) && is_string($link['url']) ? $link['url'] : null,
|
||||||
|
'resolver' => isset($link['resolver']) && is_string($link['resolver']) ? $link['resolver'] : null,
|
||||||
|
],
|
||||||
|
$definition['docs_links'],
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
$this->definitions(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'version' => 1,
|
||||||
|
'topic_count' => count($topics),
|
||||||
|
'topics' => $topics,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,465 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\ProductKnowledge;
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Ai\AiDataClassification;
|
||||||
|
use App\Support\Governance\PlatformVocabularyGlossary;
|
||||||
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class ContextualHelpResolver
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContextualHelpCatalog $catalog,
|
||||||
|
private readonly PlatformVocabularyGlossary $glossary,
|
||||||
|
private readonly ReasonPresenter $reasonPresenter,
|
||||||
|
private readonly OperatorExplanationBuilder $operatorExplanationBuilder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array{
|
||||||
|
* topic_key: string,
|
||||||
|
* surface_families: list<string>,
|
||||||
|
* headline: string,
|
||||||
|
* short_explanation: string,
|
||||||
|
* troubleshooting_steps: list<string>,
|
||||||
|
* safe_next_action: string,
|
||||||
|
* docs_links: list<array{
|
||||||
|
* label: string,
|
||||||
|
* kind: string,
|
||||||
|
* url: ?string,
|
||||||
|
* resolver: ?string
|
||||||
|
* }>,
|
||||||
|
* canonical_terms: list<string>,
|
||||||
|
* reason_label: ?string,
|
||||||
|
* diagnostic_code: ?string,
|
||||||
|
* operator_summary: ?array{
|
||||||
|
* primaryReason: string,
|
||||||
|
* nextActionText: string,
|
||||||
|
* diagnosticsAvailable: bool,
|
||||||
|
* diagnosticsSummary: ?string
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function resolve(string $topicKey, array $context = []): array
|
||||||
|
{
|
||||||
|
$definition = $this->catalog->definition($topicKey);
|
||||||
|
$reasonEnvelope = $this->providerReasonEnvelope($context);
|
||||||
|
$operatorSummary = $this->operatorSummary($context);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'topic_key' => $definition['topic_key'],
|
||||||
|
'surface_families' => $definition['surface_families'],
|
||||||
|
'headline' => $this->firstNonEmpty(
|
||||||
|
$this->stringOrNull($context['headline'] ?? null),
|
||||||
|
$definition['headline'],
|
||||||
|
),
|
||||||
|
'short_explanation' => $this->firstNonEmpty(
|
||||||
|
$this->stringOrNull($context['short_explanation'] ?? null),
|
||||||
|
$this->reasonPresenter->shortExplanation($reasonEnvelope),
|
||||||
|
$definition['short_explanation'],
|
||||||
|
),
|
||||||
|
'troubleshooting_steps' => $this->troubleshootingSteps($definition['troubleshooting_steps'], $context),
|
||||||
|
'safe_next_action' => $this->firstNonEmpty(
|
||||||
|
$this->stringOrNull($context['safe_next_action'] ?? null),
|
||||||
|
$operatorSummary['nextActionText'] ?? null,
|
||||||
|
$definition['safe_next_action'],
|
||||||
|
),
|
||||||
|
'docs_links' => $this->resolveLinks($definition['docs_links'], $context),
|
||||||
|
'canonical_terms' => $this->canonicalTerms($definition['glossary_terms']),
|
||||||
|
'reason_label' => $this->reasonPresenter->primaryLabel($reasonEnvelope),
|
||||||
|
'diagnostic_code' => $this->reasonPresenter->diagnosticCode($reasonEnvelope),
|
||||||
|
'operator_summary' => $operatorSummary,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array{
|
||||||
|
* topic_key: string,
|
||||||
|
* surface_families: list<string>,
|
||||||
|
* headline: string,
|
||||||
|
* short_explanation: string,
|
||||||
|
* troubleshooting_steps: list<string>,
|
||||||
|
* safe_next_action: string,
|
||||||
|
* docs_links: list<array{
|
||||||
|
* label: string,
|
||||||
|
* kind: string,
|
||||||
|
* url: ?string,
|
||||||
|
* resolver: ?string
|
||||||
|
* }>,
|
||||||
|
* canonical_terms: list<string>,
|
||||||
|
* reason_label: ?string,
|
||||||
|
* diagnostic_code: ?string,
|
||||||
|
* operator_summary: ?array{
|
||||||
|
* primaryReason: string,
|
||||||
|
* nextActionText: string,
|
||||||
|
* diagnosticsAvailable: bool,
|
||||||
|
* diagnosticsSummary: ?string
|
||||||
|
* }
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public function tryResolve(?string $topicKey, array $context = []): ?array
|
||||||
|
{
|
||||||
|
if (! is_string($topicKey) || trim($topicKey) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->resolve($topicKey, $context);
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* version: int,
|
||||||
|
* topic_count: int,
|
||||||
|
* topics: list<array{
|
||||||
|
* topic_key: string,
|
||||||
|
* surface_families: list<string>,
|
||||||
|
* headline: string,
|
||||||
|
* short_explanation: string,
|
||||||
|
* troubleshooting_steps: list<string>,
|
||||||
|
* safe_next_action: string,
|
||||||
|
* glossary_terms: list<string>,
|
||||||
|
* docs_links: list<array{
|
||||||
|
* label: string,
|
||||||
|
* kind: string,
|
||||||
|
* url: ?string,
|
||||||
|
* resolver: ?string
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function knowledgeSource(): array
|
||||||
|
{
|
||||||
|
return $this->catalog->knowledgeSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* use_case_key: string,
|
||||||
|
* source_family: string,
|
||||||
|
* data_classifications: list<string>,
|
||||||
|
* operational_metadata: array{version: int, topic_count: int},
|
||||||
|
* topics: list<array{
|
||||||
|
* topic_key: string,
|
||||||
|
* surface_families: list<string>,
|
||||||
|
* headline: string,
|
||||||
|
* short_explanation: string,
|
||||||
|
* troubleshooting_steps: list<string>,
|
||||||
|
* safe_next_action: string,
|
||||||
|
* glossary_terms: list<string>,
|
||||||
|
* docs_links: list<array{label: string, kind: string, url: ?string, resolver: ?string}>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function aiProductKnowledgeAnswerDraftSource(): array
|
||||||
|
{
|
||||||
|
$source = $this->knowledgeSource();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'use_case_key' => 'product_knowledge.answer_draft',
|
||||||
|
'source_family' => 'product_knowledge',
|
||||||
|
'data_classifications' => [
|
||||||
|
AiDataClassification::ProductKnowledge->value,
|
||||||
|
AiDataClassification::OperationalMetadata->value,
|
||||||
|
],
|
||||||
|
'operational_metadata' => [
|
||||||
|
'version' => (int) $source['version'],
|
||||||
|
'topic_count' => (int) $source['topic_count'],
|
||||||
|
],
|
||||||
|
'topics' => $source['topics'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $verificationReport
|
||||||
|
*/
|
||||||
|
public function primaryReasonCodeFromVerificationReport(?array $verificationReport): ?string
|
||||||
|
{
|
||||||
|
foreach ($this->relevantChecks($verificationReport) as $check) {
|
||||||
|
$reasonCode = $check['reason_code'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($reasonCode) && trim($reasonCode) !== '') {
|
||||||
|
return trim($reasonCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function topicKeyForOnboardingVerification(
|
||||||
|
?string $reasonCode,
|
||||||
|
bool $isVerificationStale,
|
||||||
|
?string $verificationOverall,
|
||||||
|
?string $runOutcome,
|
||||||
|
): ?string {
|
||||||
|
if ($isVerificationStale) {
|
||||||
|
return ContextualHelpCatalog::VERIFICATION_STALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonTopicKey = $this->onboardingTopicKeyForReason($reasonCode);
|
||||||
|
|
||||||
|
if ($reasonTopicKey !== null) {
|
||||||
|
return $reasonTopicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->isVerificationFailure($verificationOverall, $runOutcome)
|
||||||
|
? ContextualHelpCatalog::VERIFICATION_FAILED
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function topicKeyForSupportDiagnostics(
|
||||||
|
?string $reasonCode,
|
||||||
|
bool $hasIncompleteEvidence,
|
||||||
|
?string $runOutcome,
|
||||||
|
): ?string {
|
||||||
|
$reasonTopicKey = $this->supportDiagnosticsTopicKeyForReason($reasonCode);
|
||||||
|
|
||||||
|
if ($reasonTopicKey !== null) {
|
||||||
|
return $reasonTopicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($reasonCode) && trim($reasonCode) !== '') {
|
||||||
|
return $reasonCode === ProviderReasonCodes::UnknownError
|
||||||
|
? ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isVerificationFailure(null, $runOutcome)) {
|
||||||
|
return ContextualHelpCatalog::VERIFICATION_FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hasIncompleteEvidence
|
||||||
|
? ContextualHelpCatalog::DIAGNOSTIC_EVIDENCE_INCOMPLETE
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private function providerReasonEnvelope(array $context): ?ReasonResolutionEnvelope
|
||||||
|
{
|
||||||
|
$tenant = $context['tenant'] ?? null;
|
||||||
|
$reasonCode = $context['reason_code'] ?? null;
|
||||||
|
$connection = $context['connection'] ?? null;
|
||||||
|
$surface = $this->stringOrNull($context['surface'] ?? null) ?? 'detail';
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->reasonPresenter->forProviderReason(
|
||||||
|
tenant: $tenant,
|
||||||
|
reasonCode: trim($reasonCode),
|
||||||
|
connection: $connection instanceof ProviderConnection ? $connection : null,
|
||||||
|
surface: $surface,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array{
|
||||||
|
* primaryReason: string,
|
||||||
|
* nextActionText: string,
|
||||||
|
* diagnosticsAvailable: bool,
|
||||||
|
* diagnosticsSummary: ?string
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
private function operatorSummary(array $context): ?array
|
||||||
|
{
|
||||||
|
$truth = $context['artifact_truth'] ?? null;
|
||||||
|
|
||||||
|
if (! $truth instanceof ArtifactTruthEnvelope) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->operatorExplanationBuilder->compressionSummaryInputs($truth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $verificationReport
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function relevantChecks(?array $verificationReport): array
|
||||||
|
{
|
||||||
|
$checks = is_array($verificationReport['checks'] ?? null)
|
||||||
|
? $verificationReport['checks']
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return array_values(array_filter($checks, static function (mixed $check): bool {
|
||||||
|
if (! is_array($check)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $check['status'] ?? null;
|
||||||
|
|
||||||
|
return is_string($status)
|
||||||
|
&& ! in_array($status, ['pass', 'skip', 'running'], true);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function onboardingTopicKeyForReason(?string $reasonCode): ?string
|
||||||
|
{
|
||||||
|
return match ($reasonCode) {
|
||||||
|
ProviderReasonCodes::ProviderConsentMissing,
|
||||||
|
ProviderReasonCodes::ProviderConsentFailed,
|
||||||
|
ProviderReasonCodes::ProviderConsentRevoked => ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED,
|
||||||
|
ProviderReasonCodes::ProviderPermissionMissing,
|
||||||
|
ProviderReasonCodes::ProviderPermissionDenied,
|
||||||
|
ProviderReasonCodes::IntuneRbacPermissionMissing => ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING,
|
||||||
|
ProviderReasonCodes::ProviderConnectionMissing,
|
||||||
|
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
|
ProviderReasonCodes::ProviderCredentialMissing,
|
||||||
|
ProviderReasonCodes::ProviderCredentialInvalid,
|
||||||
|
ProviderReasonCodes::ProviderConnectionTypeInvalid,
|
||||||
|
ProviderReasonCodes::DedicatedCredentialMissing,
|
||||||
|
ProviderReasonCodes::DedicatedCredentialInvalid,
|
||||||
|
ProviderReasonCodes::ProviderAuthFailed,
|
||||||
|
ProviderReasonCodes::ProviderConnectionReviewRequired,
|
||||||
|
ProviderReasonCodes::ProviderBindingUnsupported,
|
||||||
|
ProviderReasonCodes::TenantTargetMismatch,
|
||||||
|
ProviderReasonCodes::PlatformIdentityMissing,
|
||||||
|
ProviderReasonCodes::PlatformIdentityIncomplete,
|
||||||
|
ProviderReasonCodes::IntuneRbacNotConfigured,
|
||||||
|
ProviderReasonCodes::IntuneRbacUnhealthy,
|
||||||
|
ProviderReasonCodes::IntuneRbacStale => ContextualHelpCatalog::CONNECTION_UNHEALTHY,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function supportDiagnosticsTopicKeyForReason(?string $reasonCode): ?string
|
||||||
|
{
|
||||||
|
if ($reasonCode === ProviderReasonCodes::ProviderPermissionRefreshFailed
|
||||||
|
|| $reasonCode === ProviderReasonCodes::NetworkUnreachable
|
||||||
|
|| $reasonCode === ProviderReasonCodes::RateLimited) {
|
||||||
|
return ContextualHelpCatalog::RETRYABLE_PROVIDER_FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->onboardingTopicKeyForReason($reasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $defaultSteps
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function troubleshootingSteps(array $defaultSteps, array $context): array
|
||||||
|
{
|
||||||
|
$contextSteps = $context['troubleshooting_steps'] ?? [];
|
||||||
|
|
||||||
|
if (! is_array($contextSteps)) {
|
||||||
|
return $defaultSteps;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedContextSteps = array_values(array_filter(array_map(
|
||||||
|
fn (mixed $step): ?string => $this->stringOrNull($step),
|
||||||
|
$contextSteps,
|
||||||
|
)));
|
||||||
|
|
||||||
|
if ($normalizedContextSteps === []) {
|
||||||
|
return $defaultSteps;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique([...$defaultSteps, ...$normalizedContextSteps]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{label: string, kind: string, url?: string, resolver?: string}> $links
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return list<array{label: string, kind: string, url: ?string, resolver: ?string}>
|
||||||
|
*/
|
||||||
|
private function resolveLinks(array $links, array $context): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(array_map(
|
||||||
|
fn (array $link): ?array => $this->resolveLink($link, $context['tenant'] ?? null),
|
||||||
|
$links,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{label: string, kind: string, url?: string, resolver?: string} $link
|
||||||
|
* @return array{label: string, kind: string, url: ?string, resolver: ?string}|null
|
||||||
|
*/
|
||||||
|
private function resolveLink(array $link, mixed $tenant): ?array
|
||||||
|
{
|
||||||
|
$label = $this->stringOrNull($link['label'] ?? null);
|
||||||
|
|
||||||
|
if ($label === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = [
|
||||||
|
'label' => $label,
|
||||||
|
'kind' => $this->stringOrNull($link['kind'] ?? null) ?? 'url',
|
||||||
|
'url' => $this->stringOrNull($link['url'] ?? null),
|
||||||
|
'resolver' => $this->stringOrNull($link['resolver'] ?? null),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || $resolved['resolver'] === null) {
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved['url'] = match ($resolved['resolver']) {
|
||||||
|
ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
|
||||||
|
ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS => RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||||
|
default => $resolved['url'],
|
||||||
|
};
|
||||||
|
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $terms
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function canonicalTerms(array $terms): array
|
||||||
|
{
|
||||||
|
return array_values(array_unique(array_filter(array_map(function (string $term): ?string {
|
||||||
|
$canonical = $this->glossary->canonicalName($term);
|
||||||
|
|
||||||
|
return $canonical !== null ? trim($canonical) : $this->stringOrNull($term);
|
||||||
|
}, $terms))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isVerificationFailure(?string $verificationOverall, ?string $runOutcome): bool
|
||||||
|
{
|
||||||
|
return in_array($verificationOverall, ['blocked', 'needs_attention'], true)
|
||||||
|
|| in_array($runOutcome, ['failed', 'blocked', 'partially_succeeded'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stringOrNull(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($value);
|
||||||
|
|
||||||
|
return $trimmed === '' ? null : $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function firstNonEmpty(?string ...$values): string
|
||||||
|
{
|
||||||
|
foreach ($values as $value) {
|
||||||
|
if ($value !== null && trim($value) !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\ProductTelemetry;
|
||||||
|
|
||||||
|
use App\Models\ProductUsageEvent;
|
||||||
|
use BackedEnum;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class ProductTelemetryRecorder
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ProductUsageEventCatalog $catalog) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
public function record(
|
||||||
|
string $eventName,
|
||||||
|
int $workspaceId,
|
||||||
|
int $tenantId,
|
||||||
|
int $userId,
|
||||||
|
string $subjectType,
|
||||||
|
string|int $subjectId,
|
||||||
|
array $metadata = [],
|
||||||
|
?DateTimeInterface $occurredAt = null,
|
||||||
|
): ProductUsageEvent {
|
||||||
|
$occurredAt ??= now();
|
||||||
|
|
||||||
|
return ProductUsageEvent::query()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'event_name' => $eventName,
|
||||||
|
'feature_area' => $this->catalog->featureArea($eventName),
|
||||||
|
'subject_type' => $this->normalizeToken($subjectType, 'subject type'),
|
||||||
|
'subject_id' => $this->normalizeSubjectId($subjectId),
|
||||||
|
'metadata' => $this->normalizeMetadata($eventName, $metadata),
|
||||||
|
'occurred_at' => Carbon::instance($occurredAt),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
* @return array<string, bool|int|string>
|
||||||
|
*/
|
||||||
|
private function normalizeMetadata(string $eventName, array $metadata): array
|
||||||
|
{
|
||||||
|
$allowedKeys = $this->catalog->allowedMetadataKeys($eventName);
|
||||||
|
$unknownKeys = array_values(array_diff(array_keys($metadata), $allowedKeys));
|
||||||
|
|
||||||
|
if ($unknownKeys !== []) {
|
||||||
|
$keys = implode(', ', $unknownKeys);
|
||||||
|
|
||||||
|
throw new InvalidArgumentException("Unsupported telemetry metadata keys [{$keys}] for [{$eventName}].");
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($metadata as $key => $value) {
|
||||||
|
if ($value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[$key] = $this->normalizeMetadataValue($value, $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool|int|string
|
||||||
|
*/
|
||||||
|
private function normalizeMetadataValue(mixed $value, string $key): bool|int|string
|
||||||
|
{
|
||||||
|
if ($value instanceof BackedEnum) {
|
||||||
|
return $this->normalizeToken((string) $value->value, $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof DateTimeInterface) {
|
||||||
|
return Carbon::instance($value)->toIso8601String();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_bool($value) || is_int($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
return $this->normalizeToken($value, $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidArgumentException("Telemetry metadata [{$key}] must be a bounded scalar, enum, or timestamp.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeSubjectId(string|int $subjectId): string
|
||||||
|
{
|
||||||
|
if (is_int($subjectId)) {
|
||||||
|
return (string) $subjectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->normalizeToken($subjectId, 'subject id');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeToken(string $value, string $field): string
|
||||||
|
{
|
||||||
|
$normalized = trim($value);
|
||||||
|
|
||||||
|
if ($normalized === '') {
|
||||||
|
throw new InvalidArgumentException("Telemetry {$field} cannot be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($normalized) > 120) {
|
||||||
|
throw new InvalidArgumentException("Telemetry {$field} is too long.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! preg_match('/\A[a-zA-Z0-9._:+\/-]+\z/', $normalized)) {
|
||||||
|
throw new InvalidArgumentException("Telemetry {$field} must be a machine-safe token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($normalized, '@')) {
|
||||||
|
throw new InvalidArgumentException("Telemetry {$field} cannot contain email-like data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\ProductTelemetry;
|
||||||
|
|
||||||
|
use App\Models\ProductUsageEvent;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
final class ProductTelemetrySummaryQuery
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ProductUsageEventCatalog $catalog) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* window: array{start_at: string, end_at: string},
|
||||||
|
* active_workspaces: int,
|
||||||
|
* total_events: int,
|
||||||
|
* families: array<string, array{label: string, count: int}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function summarize(DateTimeInterface $startAt, ?DateTimeInterface $endAt = null): array
|
||||||
|
{
|
||||||
|
$startAt = Carbon::instance($startAt);
|
||||||
|
$endAt = Carbon::instance($endAt ?? now());
|
||||||
|
|
||||||
|
$baseQuery = ProductUsageEvent::query()
|
||||||
|
->where('occurred_at', '>=', $startAt)
|
||||||
|
->where('occurred_at', '<=', $endAt);
|
||||||
|
|
||||||
|
/** @var array<string, int> $counts */
|
||||||
|
$counts = (clone $baseQuery)
|
||||||
|
->selectRaw('event_name, COUNT(*) as aggregate')
|
||||||
|
->groupBy('event_name')
|
||||||
|
->pluck('aggregate', 'event_name')
|
||||||
|
->map(static fn (mixed $count): int => (int) $count)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$families = [];
|
||||||
|
$totalEvents = 0;
|
||||||
|
|
||||||
|
foreach ($this->catalog->visibleFamilies() as $eventName => $label) {
|
||||||
|
$count = (int) ($counts[$eventName] ?? 0);
|
||||||
|
$totalEvents += $count;
|
||||||
|
|
||||||
|
$families[$eventName] = [
|
||||||
|
'label' => $label,
|
||||||
|
'count' => $count,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'window' => [
|
||||||
|
'start_at' => $startAt->toIso8601String(),
|
||||||
|
'end_at' => $endAt->toIso8601String(),
|
||||||
|
],
|
||||||
|
'active_workspaces' => (clone $baseQuery)
|
||||||
|
->distinct()
|
||||||
|
->count('workspace_id'),
|
||||||
|
'total_events' => $totalEvents,
|
||||||
|
'families' => $families,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\ProductTelemetry;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class ProductUsageEventCatalog
|
||||||
|
{
|
||||||
|
public const string ONBOARDING_CHECKPOINT_COMPLETED = 'product.onboarding.checkpoint_completed';
|
||||||
|
|
||||||
|
public const string SUPPORT_DIAGNOSTICS_OPENED = 'product.support_diagnostics.opened';
|
||||||
|
|
||||||
|
public const string OPERATIONS_STARTED = 'product.operations.started';
|
||||||
|
|
||||||
|
public const string STORED_REPORT_CREATED = 'product.stored_report.created';
|
||||||
|
|
||||||
|
public const string REVIEW_PACK_REQUESTED = 'product.review_pack.requested';
|
||||||
|
|
||||||
|
private const DEFINITIONS = [
|
||||||
|
self::ONBOARDING_CHECKPOINT_COMPLETED => [
|
||||||
|
'feature_area' => 'onboarding',
|
||||||
|
'label' => 'Onboarding checkpoints',
|
||||||
|
'metadata_keys' => ['checkpoint_key', 'lifecycle_state', 'completed_at'],
|
||||||
|
],
|
||||||
|
self::SUPPORT_DIAGNOSTICS_OPENED => [
|
||||||
|
'feature_area' => 'support_diagnostics',
|
||||||
|
'label' => 'Support diagnostics',
|
||||||
|
'metadata_keys' => ['source_surface', 'operation_type'],
|
||||||
|
],
|
||||||
|
self::OPERATIONS_STARTED => [
|
||||||
|
'feature_area' => 'operations',
|
||||||
|
'label' => 'Operations started',
|
||||||
|
'metadata_keys' => ['operation_type'],
|
||||||
|
],
|
||||||
|
self::STORED_REPORT_CREATED => [
|
||||||
|
'feature_area' => 'stored_reports',
|
||||||
|
'label' => 'Stored reports',
|
||||||
|
'metadata_keys' => ['report_type'],
|
||||||
|
],
|
||||||
|
self::REVIEW_PACK_REQUESTED => [
|
||||||
|
'feature_area' => 'review_pack',
|
||||||
|
'label' => 'Review packs requested',
|
||||||
|
'metadata_keys' => ['source_surface', 'include_operations', 'include_pii'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function names(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::DEFINITIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{feature_area: string, label: string, metadata_keys: list<string>}
|
||||||
|
*/
|
||||||
|
public function definition(string $eventName): array
|
||||||
|
{
|
||||||
|
$definition = self::DEFINITIONS[$eventName] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($definition)) {
|
||||||
|
throw new InvalidArgumentException("Unknown product telemetry event [{$eventName}].");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function featureArea(string $eventName): string
|
||||||
|
{
|
||||||
|
return $this->definition($eventName)['feature_area'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(string $eventName): string
|
||||||
|
{
|
||||||
|
return $this->definition($eventName)['label'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function allowedMetadataKeys(string $eventName): array
|
||||||
|
{
|
||||||
|
return $this->definition($eventName)['metadata_keys'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function visibleFamilies(): array
|
||||||
|
{
|
||||||
|
$families = [];
|
||||||
|
|
||||||
|
foreach ($this->names() as $eventName) {
|
||||||
|
$families[$eventName] = $this->label($eventName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $families;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,35 @@ public static function protectedValueNote(): string
|
|||||||
return 'Protected values are intentionally hidden as [REDACTED]. Secret-only changes remain detectable without revealing the value.';
|
return 'Protected values are intentionally hidden as [REDACTED]. Secret-only changes remain detectable without revealing the value.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function supportDiagnosticsNote(): string
|
||||||
|
{
|
||||||
|
return 'Support diagnostics are default-redacted. Secrets, credentials, raw provider payloads, full response bodies, and unrestricted log excerpts are intentionally excluded.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{path: ?string, reason: string, replacement_text: string}>
|
||||||
|
*/
|
||||||
|
public static function supportDiagnosticsMarkers(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'path' => 'provider_connection.credential',
|
||||||
|
'reason' => 'credential',
|
||||||
|
'replacement_text' => '[REDACTED]',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'path' => 'stored_reports.payload',
|
||||||
|
'reason' => 'raw_payload',
|
||||||
|
'replacement_text' => '[REDACTED]',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'path' => 'audit_logs.metadata.raw',
|
||||||
|
'reason' => 'restricted_log_excerpt',
|
||||||
|
'replacement_text' => '[REDACTED]',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function noteForPolicyVersion(PolicyVersion $version): ?string
|
public static function noteForPolicyVersion(PolicyVersion $version): ?string
|
||||||
{
|
{
|
||||||
if (self::fingerprintCount($version->secret_fingerprints) > 0) {
|
if (self::fingerprintCount($version->secret_fingerprints) > 0) {
|
||||||
|
|||||||
@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Support\Settings;
|
namespace App\Support\Settings;
|
||||||
|
|
||||||
|
use App\Support\Ai\AiPolicyMode;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
||||||
|
|
||||||
final class SettingsRegistry
|
final class SettingsRegistry
|
||||||
{
|
{
|
||||||
@ -17,6 +19,15 @@ public function __construct()
|
|||||||
{
|
{
|
||||||
$this->definitions = [];
|
$this->definitions = [];
|
||||||
|
|
||||||
|
$this->register(new SettingDefinition(
|
||||||
|
domain: 'ai',
|
||||||
|
key: 'policy_mode',
|
||||||
|
type: 'string',
|
||||||
|
systemDefault: AiPolicyMode::Disabled->value,
|
||||||
|
rules: ['required', 'string', 'in:disabled,private_only'],
|
||||||
|
normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)),
|
||||||
|
));
|
||||||
|
|
||||||
$this->register(new SettingDefinition(
|
$this->register(new SettingDefinition(
|
||||||
domain: 'backup',
|
domain: 'backup',
|
||||||
key: 'retention_keep_last_default',
|
key: 'retention_keep_last_default',
|
||||||
@ -218,6 +229,91 @@ static function (string $attribute, mixed $value, \Closure $fail): void {
|
|||||||
rules: ['required', 'integer', 'min:0', 'max:10080'],
|
rules: ['required', 'integer', 'min:0', 'max:10080'],
|
||||||
normalizer: static fn (mixed $value): int => (int) $value,
|
normalizer: static fn (mixed $value): int => (int) $value,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
$this->register(new SettingDefinition(
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'plan_profile',
|
||||||
|
type: 'string',
|
||||||
|
systemDefault: WorkspacePlanProfileCatalog::defaultProfileId(),
|
||||||
|
rules: [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'in:'.implode(',', WorkspacePlanProfileCatalog::profileIds()),
|
||||||
|
],
|
||||||
|
normalizer: static function (mixed $value): ?string {
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim((string) $value);
|
||||||
|
|
||||||
|
return $normalized === '' ? null : $normalized;
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->register(new SettingDefinition(
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'managed_tenant_limit_override_value',
|
||||||
|
type: 'int',
|
||||||
|
systemDefault: null,
|
||||||
|
rules: ['nullable', 'integer', 'min:0'],
|
||||||
|
normalizer: static function (mixed $value): ?int {
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $value;
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->register(new SettingDefinition(
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'managed_tenant_limit_override_reason',
|
||||||
|
type: 'string',
|
||||||
|
systemDefault: null,
|
||||||
|
rules: ['nullable', 'string', 'max:500'],
|
||||||
|
normalizer: static function (mixed $value): ?string {
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim((string) $value);
|
||||||
|
|
||||||
|
return $normalized === '' ? null : $normalized;
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->register(new SettingDefinition(
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'review_pack_generation_override_value',
|
||||||
|
type: 'bool',
|
||||||
|
systemDefault: null,
|
||||||
|
rules: ['nullable', 'boolean'],
|
||||||
|
normalizer: static function (mixed $value): ?bool {
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter_var($value, FILTER_VALIDATE_BOOL);
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->register(new SettingDefinition(
|
||||||
|
domain: 'entitlements',
|
||||||
|
key: 'review_pack_generation_override_reason',
|
||||||
|
type: 'string',
|
||||||
|
systemDefault: null,
|
||||||
|
rules: ['nullable', 'string', 'max:500'],
|
||||||
|
normalizer: static function (mixed $value): ?string {
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim((string) $value);
|
||||||
|
|
||||||
|
return $normalized === '' ? null : $normalized;
|
||||||
|
},
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\SupportRequests;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||||
|
|
||||||
|
final class SupportRequestContextBuilder
|
||||||
|
{
|
||||||
|
public const ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED = 'diagnostic_snapshot_attached';
|
||||||
|
|
||||||
|
public const ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY = 'canonical_context_only';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly SupportDiagnosticBundleBuilder $supportDiagnosticBundleBuilder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function forTenant(Tenant $tenant, User $actor, bool $attachDiagnosticSnapshot): array
|
||||||
|
{
|
||||||
|
return $this->buildEnvelope(
|
||||||
|
bundle: $this->supportDiagnosticBundleBuilder->forTenant($tenant, $actor),
|
||||||
|
attachDiagnosticSnapshot: $attachDiagnosticSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function forOperationRun(OperationRun $run, User $actor, bool $attachDiagnosticSnapshot): array
|
||||||
|
{
|
||||||
|
return $this->buildEnvelope(
|
||||||
|
bundle: $this->supportDiagnosticBundleBuilder->forOperationRun($run, $actor),
|
||||||
|
attachDiagnosticSnapshot: $attachDiagnosticSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $bundle
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function buildEnvelope(array $bundle, bool $attachDiagnosticSnapshot): array
|
||||||
|
{
|
||||||
|
$attachmentMode = $attachDiagnosticSnapshot
|
||||||
|
? self::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED
|
||||||
|
: self::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'schema_version' => 1,
|
||||||
|
'generated_from' => 'support_diagnostics_bundle',
|
||||||
|
'attachment_mode' => $attachmentMode,
|
||||||
|
'redaction_mode' => (string) data_get($bundle, 'redaction.mode', 'default_redacted'),
|
||||||
|
'primary_context' => [
|
||||||
|
'type' => (string) data_get($bundle, 'context.type'),
|
||||||
|
'workspace_id' => data_get($bundle, 'context.workspace_id'),
|
||||||
|
'tenant_id' => data_get($bundle, 'context.tenant_id'),
|
||||||
|
'operation_run_id' => data_get($bundle, 'context.operation_run_id'),
|
||||||
|
'workspace_label' => data_get($bundle, 'context.workspace_label'),
|
||||||
|
'tenant_label' => data_get($bundle, 'context.tenant_label'),
|
||||||
|
],
|
||||||
|
'canonical_context' => [
|
||||||
|
'headline' => (string) data_get($bundle, 'summary.headline', data_get($bundle, 'headline')),
|
||||||
|
'dominant_issue' => (string) data_get($bundle, 'summary.dominant_issue', data_get($bundle, 'dominant_issue')),
|
||||||
|
'freshness_state' => (string) data_get($bundle, 'freshness_state'),
|
||||||
|
'completeness_note' => data_get($bundle, 'summary.completeness_note'),
|
||||||
|
'redaction_note' => data_get($bundle, 'summary.redaction_note'),
|
||||||
|
'context' => data_get($bundle, 'context', []),
|
||||||
|
'tenant' => data_get($bundle, 'tenant'),
|
||||||
|
'operation_run' => data_get($bundle, 'operation_run'),
|
||||||
|
'sections' => $this->canonicalSections($bundle),
|
||||||
|
'notes' => is_array($bundle['notes'] ?? null)
|
||||||
|
? array_values($bundle['notes'])
|
||||||
|
: [],
|
||||||
|
],
|
||||||
|
'diagnostic_snapshot' => $attachDiagnosticSnapshot
|
||||||
|
? [
|
||||||
|
'contextual_help' => data_get($bundle, 'contextual_help'),
|
||||||
|
'sections' => is_array($bundle['sections'] ?? null)
|
||||||
|
? array_values($bundle['sections'])
|
||||||
|
: [],
|
||||||
|
'redaction' => is_array($bundle['redaction'] ?? null)
|
||||||
|
? $bundle['redaction']
|
||||||
|
: [],
|
||||||
|
'notes' => is_array($bundle['notes'] ?? null)
|
||||||
|
? array_values($bundle['notes'])
|
||||||
|
: [],
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
'omissions' => $attachDiagnosticSnapshot
|
||||||
|
? []
|
||||||
|
: [[
|
||||||
|
'type' => 'diagnostic_snapshot',
|
||||||
|
'reason' => 'omitted_without_support_diagnostics_view',
|
||||||
|
'message' => 'Redacted diagnostic evidence was omitted because the creator could not view support diagnostics.',
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $bundle
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function canonicalSections(array $bundle): array
|
||||||
|
{
|
||||||
|
if (! is_array($bundle['sections'] ?? null)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_map(
|
||||||
|
static fn (array $section): array => [
|
||||||
|
'key' => (string) ($section['key'] ?? ''),
|
||||||
|
'label' => (string) ($section['label'] ?? ''),
|
||||||
|
'availability' => (string) ($section['availability'] ?? 'missing'),
|
||||||
|
'summary' => (string) ($section['summary'] ?? ''),
|
||||||
|
'freshness_note' => $section['freshness_note'] ?? null,
|
||||||
|
'references' => is_array($section['references'] ?? null)
|
||||||
|
? array_values($section['references'])
|
||||||
|
: [],
|
||||||
|
],
|
||||||
|
$bundle['sections'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\SupportRequests;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class SupportRequestReferenceGenerator
|
||||||
|
{
|
||||||
|
public function generate(): string
|
||||||
|
{
|
||||||
|
return 'SR-'.strtoupper((string) Str::ulid());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\SupportRequests;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
final class SupportRequestSubmissionService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
|
private readonly SupportRequestContextBuilder $supportRequestContextBuilder,
|
||||||
|
private readonly SupportRequestReferenceGenerator $supportRequestReferenceGenerator,
|
||||||
|
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function submitForTenant(Tenant $tenant, User $actor, array $data): SupportRequest
|
||||||
|
{
|
||||||
|
$this->authorizeCreation($tenant, $actor);
|
||||||
|
|
||||||
|
return $this->submit(
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $actor,
|
||||||
|
data: $data,
|
||||||
|
primaryContextType: SupportRequest::PRIMARY_CONTEXT_TENANT,
|
||||||
|
operationRun: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function submitForOperationRun(OperationRun $run, User $actor, array $data): SupportRequest
|
||||||
|
{
|
||||||
|
$run->loadMissing('tenant.workspace');
|
||||||
|
|
||||||
|
$tenant = $run->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorizeCreation($tenant, $actor);
|
||||||
|
|
||||||
|
return $this->submit(
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $actor,
|
||||||
|
data: $data,
|
||||||
|
primaryContextType: SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN,
|
||||||
|
operationRun: $run,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeCreation(Tenant $tenant, User $actor): void
|
||||||
|
{
|
||||||
|
if (! $this->capabilityResolver->isMember($actor, $tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->capabilityResolver->can($actor, $tenant, Capabilities::SUPPORT_REQUESTS_CREATE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function submit(
|
||||||
|
Tenant $tenant,
|
||||||
|
User $actor,
|
||||||
|
array $data,
|
||||||
|
string $primaryContextType,
|
||||||
|
?OperationRun $operationRun,
|
||||||
|
): SupportRequest {
|
||||||
|
$validated = $this->validate($data);
|
||||||
|
$attachDiagnosticSnapshot = $this->capabilityResolver->can($actor, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||||
|
|
||||||
|
$contextEnvelope = $operationRun instanceof OperationRun
|
||||||
|
? $this->supportRequestContextBuilder->forOperationRun($operationRun, $actor, $attachDiagnosticSnapshot)
|
||||||
|
: $this->supportRequestContextBuilder->forTenant($tenant, $actor, $attachDiagnosticSnapshot);
|
||||||
|
|
||||||
|
$contactName = $validated['contact_name'] ?? $this->normalizeNullableString($actor->name) ?? $this->normalizeNullableString($actor->email);
|
||||||
|
$contactEmail = $validated['contact_email'] ?? $this->normalizeNullableString($actor->email);
|
||||||
|
$connection = SupportRequest::query()->getModel()->getConnection();
|
||||||
|
|
||||||
|
return $connection->transaction(function () use (
|
||||||
|
$actor,
|
||||||
|
$contactEmail,
|
||||||
|
$contactName,
|
||||||
|
$contextEnvelope,
|
||||||
|
$operationRun,
|
||||||
|
$primaryContextType,
|
||||||
|
$tenant,
|
||||||
|
$validated,
|
||||||
|
): SupportRequest {
|
||||||
|
$supportRequest = SupportRequest::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
|
||||||
|
'initiated_by_user_id' => (int) $actor->getKey(),
|
||||||
|
'internal_reference' => $this->supportRequestReferenceGenerator->generate(),
|
||||||
|
'primary_context_type' => $primaryContextType,
|
||||||
|
'attachment_mode' => (string) data_get($contextEnvelope, 'attachment_mode', SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY),
|
||||||
|
'severity' => $validated['severity'],
|
||||||
|
'summary' => $validated['summary'],
|
||||||
|
'reproduction_notes' => $validated['reproduction_notes'],
|
||||||
|
'contact_name' => $contactName,
|
||||||
|
'contact_email' => $contactEmail,
|
||||||
|
'context_envelope' => $contextEnvelope,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$supportRequest->loadMissing(['tenant.workspace']);
|
||||||
|
|
||||||
|
$this->workspaceAuditLogger->logSupportRequestCreated($supportRequest, $actor);
|
||||||
|
|
||||||
|
return $supportRequest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array{
|
||||||
|
* severity: string,
|
||||||
|
* summary: string,
|
||||||
|
* reproduction_notes: ?string,
|
||||||
|
* contact_name: ?string,
|
||||||
|
* contact_email: ?string,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function validate(array $data): array
|
||||||
|
{
|
||||||
|
$validated = validator(
|
||||||
|
[
|
||||||
|
'severity' => $data['severity'] ?? SupportRequest::SEVERITY_NORMAL,
|
||||||
|
'summary' => $data['summary'] ?? null,
|
||||||
|
'reproduction_notes' => $data['reproduction_notes'] ?? null,
|
||||||
|
'contact_name' => $data['contact_name'] ?? null,
|
||||||
|
'contact_email' => $data['contact_email'] ?? null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'severity' => ['required', 'string', Rule::in(SupportRequest::severityValues())],
|
||||||
|
'summary' => ['required', 'string'],
|
||||||
|
'reproduction_notes' => ['nullable', 'string'],
|
||||||
|
'contact_name' => ['nullable', 'string'],
|
||||||
|
'contact_email' => ['nullable', 'email'],
|
||||||
|
],
|
||||||
|
)->validate();
|
||||||
|
|
||||||
|
$validated['summary'] = trim((string) $validated['summary']);
|
||||||
|
|
||||||
|
if ($validated['summary'] === '') {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'summary' => 'The summary field is required.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated['reproduction_notes'] = $this->normalizeNullableString($validated['reproduction_notes'] ?? null);
|
||||||
|
$validated['contact_name'] = $this->normalizeNullableString($validated['contact_name'] ?? null);
|
||||||
|
$validated['contact_email'] = $this->normalizeNullableString($validated['contact_email'] ?? null);
|
||||||
|
|
||||||
|
return $validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeNullableString(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim($value);
|
||||||
|
|
||||||
|
return $normalized === '' ? null : $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,6 +39,7 @@
|
|||||||
use App\Filament\System\Pages\Dashboard as SystemDashboard;
|
use App\Filament\System\Pages\Dashboard as SystemDashboard;
|
||||||
use App\Filament\System\Pages\Directory\ViewTenant as SystemDirectoryViewTenant;
|
use App\Filament\System\Pages\Directory\ViewTenant as SystemDirectoryViewTenant;
|
||||||
use App\Filament\System\Pages\Directory\ViewWorkspace as SystemDirectoryViewWorkspace;
|
use App\Filament\System\Pages\Directory\ViewWorkspace as SystemDirectoryViewWorkspace;
|
||||||
|
use App\Filament\System\Pages\Ops\Controls;
|
||||||
use App\Filament\System\Pages\Ops\Runbooks;
|
use App\Filament\System\Pages\Ops\Runbooks;
|
||||||
use App\Filament\System\Pages\Ops\ViewRun;
|
use App\Filament\System\Pages\Ops\ViewRun;
|
||||||
use App\Filament\System\Pages\RepairWorkspaceOwners;
|
use App\Filament\System\Pages\RepairWorkspaceOwners;
|
||||||
@ -661,6 +662,32 @@ public static function spec195ResidualSurfaceInventory(): array
|
|||||||
'mustRemainBaselineExempt' => false,
|
'mustRemainBaselineExempt' => false,
|
||||||
'mustNotRemainBaselineExempt' => true,
|
'mustNotRemainBaselineExempt' => true,
|
||||||
],
|
],
|
||||||
|
Controls::class => [
|
||||||
|
'surfaceKey' => 'system_ops_controls',
|
||||||
|
'surfaceName' => 'System Ops Controls',
|
||||||
|
'pageClass' => Controls::class,
|
||||||
|
'panelPlane' => 'system',
|
||||||
|
'surfaceKind' => 'system_utility',
|
||||||
|
'discoveryState' => 'outside_primary_discovery',
|
||||||
|
'closureDecision' => 'separately_governed',
|
||||||
|
'reasonCategory' => 'workflow_specific_governance',
|
||||||
|
'explicitReason' => 'Operational controls is a dedicated system control workbench with confirmation-backed pause, resume, and history actions plus restore-gate coupling, so it remains governed by focused workflow tests instead of the generic declaration-backed contract.',
|
||||||
|
'evidence' => [
|
||||||
|
[
|
||||||
|
'kind' => 'feature_livewire_test',
|
||||||
|
'reference' => 'tests/Feature/System/OpsControls/OperationalControlManagementTest.php',
|
||||||
|
'proves' => 'The controls page keeps capability-gated operational-control actions, confirmation semantics, scope previews, and audited pause or resume behavior under dedicated coverage.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'kind' => 'feature_livewire_test',
|
||||||
|
'reference' => 'tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php',
|
||||||
|
'proves' => 'Restore execution stays coupled to the shared operational-control workflow, including blocked execution and non-retroactive pause behavior after acceptance.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'followUpAction' => 'add_guard_only',
|
||||||
|
'mustRemainBaselineExempt' => false,
|
||||||
|
'mustNotRemainBaselineExempt' => true,
|
||||||
|
],
|
||||||
RepairWorkspaceOwners::class => [
|
RepairWorkspaceOwners::class => [
|
||||||
'surfaceKey' => 'repair_workspace_owners',
|
'surfaceKey' => 'repair_workspace_owners',
|
||||||
'surfaceName' => 'Repair Workspace Owners',
|
'surfaceName' => 'Repair Workspace Owners',
|
||||||
|
|||||||
@ -149,9 +149,6 @@
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false),
|
|
||||||
|
|
||||||
'supported_policy_types' => [
|
'supported_policy_types' => [
|
||||||
[
|
[
|
||||||
'type' => 'deviceConfiguration',
|
'type' => 'deviceConfiguration',
|
||||||
@ -574,6 +571,8 @@
|
|||||||
'retention_days' => (int) env('TENANTPILOT_STORED_REPORTS_RETENTION_DAYS', 90),
|
'retention_days' => (int) env('TENANTPILOT_STORED_REPORTS_RETENTION_DAYS', 90),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'product_usage_event_retention_days' => (int) env('TENANTPILOT_PRODUCT_USAGE_EVENT_RETENTION_DAYS', 90),
|
||||||
|
|
||||||
'display' => [
|
'display' => [
|
||||||
'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false),
|
'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false),
|
||||||
'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000),
|
'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000),
|
||||||
|
|||||||
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\OperationalControlActivation;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<OperationalControlActivation>
|
||||||
|
*/
|
||||||
|
class OperationalControlActivationFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = OperationalControlActivation::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'control_key' => 'restore.execute',
|
||||||
|
'scope_type' => 'global',
|
||||||
|
'workspace_id' => null,
|
||||||
|
'reason_text' => fake()->sentence(),
|
||||||
|
'expires_at' => null,
|
||||||
|
'created_by_platform_user_id' => PlatformUser::factory(),
|
||||||
|
'updated_by_platform_user_id' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forControl(string $controlKey): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (): array => [
|
||||||
|
'control_key' => $controlKey,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forGlobalScope(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (): array => [
|
||||||
|
'scope_type' => 'global',
|
||||||
|
'workspace_id' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workspaceScoped(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (): array => [
|
||||||
|
'scope_type' => 'workspace',
|
||||||
|
'workspace_id' => Workspace::factory(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\ProductUsageEvent;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<ProductUsageEvent>
|
||||||
|
*/
|
||||||
|
class ProductUsageEventFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = ProductUsageEvent::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$catalog = new ProductUsageEventCatalog();
|
||||||
|
$eventName = ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tenant_id' => Tenant::factory()->for(Workspace::factory()),
|
||||||
|
'workspace_id' => function (array $attributes): int {
|
||||||
|
$tenantId = $attributes['tenant_id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_numeric($tenantId)) {
|
||||||
|
return (int) Workspace::factory()->create()->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || $tenant->workspace_id === null) {
|
||||||
|
return (int) Workspace::factory()->create()->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $tenant->workspace_id;
|
||||||
|
},
|
||||||
|
'user_id' => User::factory(),
|
||||||
|
'event_name' => $eventName,
|
||||||
|
'feature_area' => $catalog->featureArea($eventName),
|
||||||
|
'subject_type' => 'tenant_onboarding_session',
|
||||||
|
'subject_id' => (string) fake()->numberBetween(1, 999999),
|
||||||
|
'metadata' => [
|
||||||
|
'checkpoint_key' => 'tenant_connected',
|
||||||
|
'lifecycle_state' => 'completed',
|
||||||
|
],
|
||||||
|
'occurred_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
public function forEvent(string $eventName, array $metadata = []): static
|
||||||
|
{
|
||||||
|
$catalog = new ProductUsageEventCatalog();
|
||||||
|
|
||||||
|
return $this->state(fn (): array => [
|
||||||
|
'event_name' => $eventName,
|
||||||
|
'feature_area' => $catalog->featureArea($eventName),
|
||||||
|
'metadata' => $metadata,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
apps/platform/database/factories/SupportRequestFactory.php
Normal file
98
apps/platform/database/factories/SupportRequestFactory.php
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SupportRequest>
|
||||||
|
*/
|
||||||
|
class SupportRequestFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = SupportRequest::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenant_id' => Tenant::factory(),
|
||||||
|
'initiated_by_user_id' => User::factory(),
|
||||||
|
'internal_reference' => 'SR-'.strtoupper((string) Str::ulid()),
|
||||||
|
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
|
||||||
|
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
|
||||||
|
'severity' => SupportRequest::SEVERITY_NORMAL,
|
||||||
|
'summary' => fake()->sentence(),
|
||||||
|
'reproduction_notes' => fake()->optional()->paragraph(),
|
||||||
|
'contact_name' => fake()->name(),
|
||||||
|
'contact_email' => fake()->safeEmail(),
|
||||||
|
'context_envelope' => [
|
||||||
|
'schema_version' => 1,
|
||||||
|
'generated_from' => 'factory',
|
||||||
|
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
|
||||||
|
'primary_context' => [
|
||||||
|
'type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
|
||||||
|
],
|
||||||
|
'canonical_context' => [
|
||||||
|
'sections' => [],
|
||||||
|
],
|
||||||
|
'diagnostic_snapshot' => [
|
||||||
|
'sections' => [],
|
||||||
|
],
|
||||||
|
'omissions' => [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canonicalContextOnly(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY,
|
||||||
|
'context_envelope' => array_replace_recursive($attributes['context_envelope'] ?? [], [
|
||||||
|
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY,
|
||||||
|
'diagnostic_snapshot' => null,
|
||||||
|
'omissions' => [[
|
||||||
|
'type' => 'diagnostic_snapshot',
|
||||||
|
'reason' => 'omitted_without_support_diagnostics_view',
|
||||||
|
]],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forOperationRun(OperationRun $operationRun): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (): array => [
|
||||||
|
'tenant_id' => (int) $operationRun->tenant_id,
|
||||||
|
'workspace_id' => (int) $operationRun->workspace_id,
|
||||||
|
'operation_run_id' => (int) $operationRun->getKey(),
|
||||||
|
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN,
|
||||||
|
'context_envelope' => [
|
||||||
|
'schema_version' => 1,
|
||||||
|
'generated_from' => 'factory',
|
||||||
|
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
|
||||||
|
'primary_context' => [
|
||||||
|
'type' => SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN,
|
||||||
|
'tenant_id' => (int) $operationRun->tenant_id,
|
||||||
|
'operation_run_id' => (int) $operationRun->getKey(),
|
||||||
|
],
|
||||||
|
'canonical_context' => [
|
||||||
|
'sections' => [],
|
||||||
|
],
|
||||||
|
'diagnostic_snapshot' => [
|
||||||
|
'sections' => [],
|
||||||
|
],
|
||||||
|
'omissions' => [],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('operational_control_activations', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('control_key');
|
||||||
|
$table->string('scope_type');
|
||||||
|
$table->foreignId('workspace_id')->nullable()->constrained('workspaces')->cascadeOnDelete();
|
||||||
|
$table->text('reason_text');
|
||||||
|
$table->timestampTz('expires_at')->nullable();
|
||||||
|
$table->foreignId('created_by_platform_user_id')->constrained('platform_users')->restrictOnDelete();
|
||||||
|
$table->foreignId('updated_by_platform_user_id')->nullable()->constrained('platform_users')->nullOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['control_key', 'scope_type']);
|
||||||
|
$table->index(['workspace_id', 'control_key']);
|
||||||
|
$table->index('expires_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::statement("CREATE UNIQUE INDEX operational_control_activations_global_unique ON operational_control_activations (control_key) WHERE scope_type = 'global'");
|
||||||
|
DB::statement("CREATE UNIQUE INDEX operational_control_activations_workspace_unique ON operational_control_activations (control_key, workspace_id) WHERE scope_type = 'workspace' AND workspace_id IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('operational_control_activations');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('product_usage_events', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->string('event_name', 120);
|
||||||
|
$table->string('feature_area', 60);
|
||||||
|
$table->string('subject_type', 120);
|
||||||
|
$table->string('subject_id', 120);
|
||||||
|
$table->jsonb('metadata')->default('{}');
|
||||||
|
$table->timestampTz('occurred_at');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['workspace_id', 'tenant_id', 'occurred_at']);
|
||||||
|
$table->index(['event_name', 'occurred_at']);
|
||||||
|
$table->index(['subject_type', 'subject_id', 'occurred_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('product_usage_events');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('support_requests', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||||
|
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
|
||||||
|
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
|
||||||
|
$table->foreignId('initiated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->string('internal_reference')->unique();
|
||||||
|
$table->string('primary_context_type');
|
||||||
|
$table->string('attachment_mode');
|
||||||
|
$table->string('severity');
|
||||||
|
$table->text('summary');
|
||||||
|
$table->text('reproduction_notes')->nullable();
|
||||||
|
$table->string('contact_name')->nullable();
|
||||||
|
$table->string('contact_email')->nullable();
|
||||||
|
$table->jsonb('context_envelope')->default('{}');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['workspace_id', 'tenant_id']);
|
||||||
|
$table->index(['tenant_id', 'created_at']);
|
||||||
|
$table->index(['operation_run_id', 'created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('support_requests');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -42,6 +42,7 @@ public function run(): void
|
|||||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||||
PlatformCapabilities::RUNBOOKS_RUN,
|
PlatformCapabilities::RUNBOOKS_RUN,
|
||||||
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
||||||
|
PlatformCapabilities::OPS_CONTROLS_MANAGE,
|
||||||
],
|
],
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -0,0 +1,80 @@
|
|||||||
|
@php
|
||||||
|
$help = is_array($help ?? null) ? $help : [];
|
||||||
|
$links = is_array($help['docs_links'] ?? null) ? $help['docs_links'] : [];
|
||||||
|
$steps = is_array($help['troubleshooting_steps'] ?? null) ? $help['troubleshooting_steps'] : [];
|
||||||
|
$headline = is_string($help['headline'] ?? null) && trim((string) ($help['headline'] ?? '')) !== ''
|
||||||
|
? (string) ($help['headline'])
|
||||||
|
: 'Contextual help';
|
||||||
|
$reasonLabel = is_string($help['reason_label'] ?? null) && trim((string) ($help['reason_label'] ?? '')) !== ''
|
||||||
|
? (string) $help['reason_label']
|
||||||
|
: null;
|
||||||
|
$showReasonLabel = $reasonLabel !== null && trim(mb_strtolower($reasonLabel)) !== trim(mb_strtolower($headline));
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($help !== [])
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/80" data-testid="contextual-help-block">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge color="info" size="sm">
|
||||||
|
Contextual help
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if ($showReasonLabel)
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
{{ $reasonLabel }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $headline }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
{{ (string) ($help['short_explanation'] ?? '') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (is_string($help['safe_next_action'] ?? null) && trim((string) ($help['safe_next_action'] ?? '')) !== '')
|
||||||
|
<x-filament::callout
|
||||||
|
color="info"
|
||||||
|
heading="Safe next action"
|
||||||
|
:description="(string) $help['safe_next_action']"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($steps !== [])
|
||||||
|
<ul class="list-disc space-y-1 pl-5 text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
@foreach ($steps as $step)
|
||||||
|
@if (is_string($step) && trim($step) !== '')
|
||||||
|
<li>{{ $step }}</li>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($links !== [])
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach ($links as $link)
|
||||||
|
@php
|
||||||
|
$linkUrl = is_string($link['url'] ?? null) && trim((string) ($link['url'] ?? '')) !== ''
|
||||||
|
? (string) $link['url']
|
||||||
|
: null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($linkUrl)
|
||||||
|
<x-filament::button
|
||||||
|
tag="a"
|
||||||
|
:href="$linkUrl"
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{{ (string) ($link['label'] ?? 'Open') }}
|
||||||
|
</x-filament::button>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@ -6,6 +6,7 @@
|
|||||||
$redactionNotes = is_array($redactionNotes ?? null)
|
$redactionNotes = is_array($redactionNotes ?? null)
|
||||||
? array_values(array_filter($redactionNotes, 'is_string'))
|
? array_values(array_filter($redactionNotes, 'is_string'))
|
||||||
: [];
|
: [];
|
||||||
|
$contextualHelp = is_array($contextualHelp ?? null) ? $contextualHelp : null;
|
||||||
$assistVisibility = is_array($assistVisibility ?? null) ? $assistVisibility : [];
|
$assistVisibility = is_array($assistVisibility ?? null) ? $assistVisibility : [];
|
||||||
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
|
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
|
||||||
? trim((string) $assistActionName)
|
? trim((string) $assistActionName)
|
||||||
@ -14,12 +15,6 @@
|
|||||||
? trim((string) $technicalDetailsActionName)
|
? trim((string) $technicalDetailsActionName)
|
||||||
: 'wizardVerificationTechnicalDetails';
|
: 'wizardVerificationTechnicalDetails';
|
||||||
$showAssist = (bool) ($assistVisibility['is_visible'] ?? false);
|
$showAssist = (bool) ($assistVisibility['is_visible'] ?? false);
|
||||||
$assistReason = is_string($assistVisibility['reason'] ?? null) ? (string) $assistVisibility['reason'] : 'hidden_irrelevant';
|
|
||||||
$assistDescription = match ($assistReason) {
|
|
||||||
'permission_blocked' => 'Stored permission diagnostics show blockers. Review them without leaving onboarding.',
|
|
||||||
'permission_attention' => 'Stored permission diagnostics need attention before you rerun verification.',
|
|
||||||
default => 'Review required permissions without leaving onboarding.',
|
|
||||||
};
|
|
||||||
$completedAt = is_string($run['completed_at'] ?? null) && trim((string) $run['completed_at']) !== '' ? (string) $run['completed_at'] : null;
|
$completedAt = is_string($run['completed_at'] ?? null) && trim((string) $run['completed_at']) !== '' ? (string) $run['completed_at'] : null;
|
||||||
$completedAtLabel = null;
|
$completedAtLabel = null;
|
||||||
|
|
||||||
@ -52,7 +47,7 @@
|
|||||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<x-filament::section
|
<x-filament::section
|
||||||
heading="Verification report"
|
heading="Stored verification details"
|
||||||
:description="$completedAtLabel ? ('Completed: ' . $completedAtLabel) : 'Stored details for the latest verification operation.'"
|
:description="$completedAtLabel ? ('Completed: ' . $completedAtLabel) : 'Stored details for the latest verification operation.'"
|
||||||
>
|
>
|
||||||
@if ($runState === 'no_run')
|
@if ($runState === 'no_run')
|
||||||
@ -113,28 +108,8 @@
|
|||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($showAssist)
|
@if ($contextualHelp !== null)
|
||||||
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 shadow-sm dark:border-warning-700 dark:bg-warning-950/40">
|
@include('filament.components.product-knowledge.contextual-help', ['help' => $contextualHelp])
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-sm font-semibold text-warning-950 dark:text-warning-50">
|
|
||||||
Required permissions assist
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-warning-900 dark:text-warning-100">
|
|
||||||
{{ $assistDescription }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-filament::button
|
|
||||||
size="sm"
|
|
||||||
color="warning"
|
|
||||||
data-testid="verification-assist-trigger"
|
|
||||||
wire:click="mountAction('{{ $assistActionName }}')"
|
|
||||||
>
|
|
||||||
View required permissions
|
|
||||||
</x-filament::button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@include('filament.components.verification-report-viewer', [
|
@include('filament.components.verification-report-viewer', [
|
||||||
|
|||||||
@ -0,0 +1,167 @@
|
|||||||
|
@php
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $bundle */
|
||||||
|
$summary = is_array($bundle['summary'] ?? null) ? $bundle['summary'] : [];
|
||||||
|
$context = is_array($bundle['context'] ?? null) ? $bundle['context'] : [];
|
||||||
|
$sections = is_array($bundle['sections'] ?? null) ? $bundle['sections'] : [];
|
||||||
|
$redaction = is_array($bundle['redaction'] ?? null) ? $bundle['redaction'] : [];
|
||||||
|
$notes = is_array($bundle['notes'] ?? null) ? $bundle['notes'] : [];
|
||||||
|
|
||||||
|
$availabilityColor = static function (?string $availability): string {
|
||||||
|
return match ($availability) {
|
||||||
|
'available', 'current', 'fresh', 'ready' => 'success',
|
||||||
|
'partial', 'stale' => 'warning',
|
||||||
|
'error', 'missing', 'unavailable' => 'danger',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
$referenceDescription = static function (array $reference): string {
|
||||||
|
$parts = [
|
||||||
|
is_string($reference['type'] ?? null) && trim((string) $reference['type']) !== ''
|
||||||
|
? (string) $reference['type']
|
||||||
|
: 'reference',
|
||||||
|
is_string($reference['availability'] ?? null) && trim((string) $reference['availability']) !== ''
|
||||||
|
? (string) $reference['availability']
|
||||||
|
: 'missing',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (is_string($reference['freshness_note'] ?? null) && trim((string) $reference['freshness_note']) !== '') {
|
||||||
|
$parts[] = (string) $reference['freshness_note'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' - ', $parts);
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<x-filament::section
|
||||||
|
:heading="data_get($summary, 'headline', data_get($bundle, 'headline', 'Support diagnostics'))"
|
||||||
|
:description="data_get($summary, 'dominant_issue', data_get($bundle, 'dominant_issue', 'No dominant issue available.'))"
|
||||||
|
>
|
||||||
|
<x-slot name="afterHeader">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
{{ data_get($context, 'type', data_get($bundle, 'context_type', 'tenant')) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
{{ data_get($summary, 'freshness_state', data_get($bundle, 'freshness_state', 'mixed')) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="warning" size="sm">
|
||||||
|
{{ str_replace('_', '-', (string) data_get($redaction, 'mode', data_get($bundle, 'redaction_mode', 'default-redacted'))) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<dl class="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Workspace</dt>
|
||||||
|
<dd class="text-gray-950 dark:text-white">{{ data_get($context, 'workspace_label', 'Workspace unavailable') }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Tenant</dt>
|
||||||
|
<dd class="text-gray-950 dark:text-white">{{ data_get($context, 'tenant_label', 'Tenant unavailable') }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
@if (is_array($bundle['contextual_help'] ?? null))
|
||||||
|
@include('filament.components.product-knowledge.contextual-help', ['help' => $bundle['contextual_help']])
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($notes !== [])
|
||||||
|
<x-filament::section
|
||||||
|
heading="Support notes"
|
||||||
|
description="The bundle stays read-only and redacted even when the source records include provider-only details."
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($notes as $note)
|
||||||
|
@if (is_string($note) && trim($note) !== '')
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<x-filament::badge color="warning" size="sm">Note</x-filament::badge>
|
||||||
|
<p class="text-sm leading-6 text-gray-700 dark:text-gray-200">{{ $note }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($sections as $section)
|
||||||
|
@php
|
||||||
|
$references = is_array($section['references'] ?? null) ? $section['references'] : [];
|
||||||
|
$markers = is_array($section['redaction_markers'] ?? null) ? $section['redaction_markers'] : [];
|
||||||
|
$availability = is_string($section['availability'] ?? null) && trim((string) $section['availability']) !== ''
|
||||||
|
? (string) $section['availability']
|
||||||
|
: 'missing';
|
||||||
|
$sectionLabel = $section['label'] ?? $section['key'] ?? 'Section';
|
||||||
|
$sectionSummary = $section['summary'] ?? 'No summary available.';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-filament::section
|
||||||
|
:heading="$sectionLabel"
|
||||||
|
:description="$sectionSummary"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<x-slot name="afterHeader">
|
||||||
|
<x-filament::badge :color="$availabilityColor($availability)" size="sm">
|
||||||
|
{{ $availability }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
@if (is_string($section['freshness_note'] ?? null) && trim((string) $section['freshness_note']) !== '')
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ $section['freshness_note'] }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($references !== [])
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($references as $reference)
|
||||||
|
@php
|
||||||
|
$referenceLabel = $reference['label'] ?? 'Reference unavailable';
|
||||||
|
$referenceUrl = is_string($reference['url'] ?? null) && trim((string) $reference['url']) !== ''
|
||||||
|
? (string) $reference['url']
|
||||||
|
: null;
|
||||||
|
$referenceActionLabel = $reference['action_label'] ?? ($referenceUrl ? 'Open' : 'Unavailable');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-filament::section
|
||||||
|
:heading="$referenceLabel"
|
||||||
|
:description="$referenceDescription($reference)"
|
||||||
|
compact
|
||||||
|
secondary
|
||||||
|
>
|
||||||
|
<x-slot name="afterHeader">
|
||||||
|
@if ($referenceUrl)
|
||||||
|
<x-filament::link :href="$referenceUrl" size="sm">
|
||||||
|
{{ $referenceActionLabel }}
|
||||||
|
</x-filament::link>
|
||||||
|
@else
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
{{ $referenceActionLabel }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</x-slot>
|
||||||
|
</x-filament::section>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($markers !== [])
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach ($markers as $marker)
|
||||||
|
<x-filament::badge color="warning" size="sm">
|
||||||
|
{{ trim((string) (($marker['replacement_text'] ?? '[REDACTED]').' '.Str::of((string) ($marker['reason'] ?? 'redacted'))->replace('_', ' '))) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<x-filament::section>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Customer-safe review workspace
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Opening a row returns to the existing tenant review detail so evidence, review packs, and audit-aware proof remain on their canonical tenant-scoped surfaces.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
{{ $this->table }}
|
||||||
|
</x-filament-panels::page>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user