Compare commits
50 Commits
dev
...
269-operat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
967b022b26 | ||
|
|
c1f4ebaa05 | ||
| b2ec2f032f | |||
| a146b14208 | |||
| 71596ae590 | |||
| 35b59eb628 | |||
| ca61fa17dc | |||
| 867bd92370 | |||
| 6bf8e7f76b | |||
| 3aeb0d04b8 | |||
| 23ef20f86d | |||
|
|
df5a0e067d | ||
|
|
15af199d4f | ||
| 11247c1537 | |||
| b05d5c52d4 | |||
| 8f1ceb70ec | |||
| 25e1f69513 | |||
| feeaadd5ad | |||
| bcabb14480 | |||
| eae06bfe05 | |||
| 866875559f | |||
|
|
0517305381 | ||
| 966b7af472 | |||
|
|
1bf369b561 | ||
|
|
a2bb5b7729 | ||
|
|
bb78049271 | ||
| 7d17d39060 | |||
|
|
a35cd88bff | ||
| 926b0fe4f3 | |||
|
|
a74a6791ad | ||
| 52ebf63af1 | |||
|
|
2e2b125107 | ||
|
|
4b0dc2a62e | ||
|
|
34351a281d | ||
| 51ea80ca05 | |||
|
|
e36bd3ca9c | ||
| b511b08371 | |||
|
|
f53f149f99 | ||
| 2fa8fc0f87 | |||
|
|
44e6a1eb05 | ||
|
|
4f7c1a6c94 | ||
|
|
4325e1ed8d | ||
|
|
4ae4c2ee95 | ||
|
|
32b6dcb937 | ||
|
|
f7bc4f2787 | ||
|
|
0739018ee5 | ||
|
|
9a02261f5c | ||
|
|
65ec1d5904 | ||
|
|
f05857c276 | ||
|
|
9f5d3293c5 |
@ -4,11 +4,21 @@
|
|||||||
},
|
},
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"laravel-boost": {
|
"laravel-boost": {
|
||||||
"command": "vendor/bin/sail",
|
"command": "/Users/ahmeddarrazi/Documents/projects/wt-plattform/scripts/platform-sail",
|
||||||
"args": [
|
"args": [
|
||||||
"artisan",
|
"artisan",
|
||||||
"boost:mcp"
|
"boost:mcp"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"kroki": {
|
||||||
|
"command": "node",
|
||||||
|
"args": [
|
||||||
|
"/Users/ahmeddarrazi/Documents/projects/kroki-mcp-server/dist/index.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"KROKI_BASE_URL": "http://development-kroki-ccl69b-553648-194-164-192-109.traefik.me",
|
||||||
|
"KROKI_TIMEOUT_MS": "10000"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
14
.github/agents/copilot-instructions.md
vendored
14
.github/agents/copilot-instructions.md
vendored
@ -266,6 +266,14 @@ ## Active Technologies
|
|||||||
- PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state)
|
- PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state)
|
||||||
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities` (253-remove-findings-backfill-runtime-surfaces)
|
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities` (253-remove-findings-backfill-runtime-surfaces)
|
||||||
- PostgreSQL existing `findings`, `operation_runs`, `audit_logs`, and related runtime tables only; no new persistence, migration, or data backfill is planned (253-remove-findings-backfill-runtime-surfaces)
|
- PostgreSQL existing `findings`, `operation_runs`, `audit_logs`, and related runtime tables only; no new persistence, migration, or data backfill is planned (253-remove-findings-backfill-runtime-surfaces)
|
||||||
|
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing canonical-control, evidence, tenant-review, RBAC, localization, and audit seams (259-compliance-evidence-mapping)
|
||||||
|
- PostgreSQL via existing `tenant_reviews`, `tenant_review_sections`, `evidence_snapshots`, `evidence_snapshot_items`, `findings`, `finding_exceptions`, memberships, and `audit_logs`; no new persistence table planned (259-compliance-evidence-mapping)
|
||||||
|
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing `TenantReviewComposer`, `TenantReviewSectionFactory`, `ComplianceEvidenceMappingV1`, `ReviewPackService`, `ArtifactTruthPresenter`, capability helpers, localization copy, and shared audit infrastructure (260-governance-service-packaging)
|
||||||
|
- PostgreSQL via existing `tenant_reviews`, `tenant_review_sections`, `review_packs`, `evidence_snapshots`, `evidence_snapshot_items`, `stored_reports`, `findings`, `finding_exceptions`, `finding_exception_decisions`, memberships, and `audit_logs`; no new persistence planned (260-governance-service-packaging)
|
||||||
|
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Tailwind v4, Pest v4, existing dashboard widgets, `TenantGovernanceAggregateResolver`, `RestoreSafetyResolver`, `BackupHealthDashboardSignal`, `OperationRunLinks`, `RequiredPermissionsLinks`, `TenantRequiredPermissionsViewModelBuilder`, tenant review/evidence/review-pack resources, shared badge rendering, and capability helpers (266-tenant-dashboard-productization-v1)
|
||||||
|
- PostgreSQL via existing tenant-owned findings, exceptions, operation runs, evidence snapshots, review packs, tenant reviews, backup or restore evidence records, memberships, and audit logs; no new persistence planned (266-tenant-dashboard-productization-v1)
|
||||||
|
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Laravel translator, existing `App\Services\Localization\LocaleResolver`, `App\Http\Controllers\LocalizationController`, current `localization.review.*` and locale feedback catalogs, `CustomerReviewWorkspace`, `TenantReviewResource`, `ViewTenantReview`, current review-pack and evidence resource paths, shared RBAC and audit helpers (275-customer-facing-localization-adoption)
|
||||||
|
- PostgreSQL via existing `users.preferred_locale`, existing workspace localization setting, existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, memberships, and `audit_logs`; translation catalogs in `apps/platform/lang/en` and `apps/platform/lang/de`; no new persistence planned (275-customer-facing-localization-adoption)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -300,9 +308,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 253-remove-findings-backfill-runtime-surfaces: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities`
|
- 275-customer-facing-localization-adoption: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Laravel translator, existing `App\Services\Localization\LocaleResolver`, `App\Http\Controllers\LocalizationController`, current `localization.review.*` and locale feedback catalogs, `CustomerReviewWorkspace`, `TenantReviewResource`, `ViewTenantReview`, current review-pack and evidence resource paths, shared RBAC and audit helpers
|
||||||
- 251-commercial-entitlements-billing-state: Added PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page
|
- 266-tenant-dashboard-productization-v1: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Tailwind v4, Pest v4, existing dashboard widgets, `TenantGovernanceAggregateResolver`, `RestoreSafetyResolver`, `BackupHealthDashboardSignal`, `OperationRunLinks`, `RequiredPermissionsLinks`, `TenantRequiredPermissionsViewModelBuilder`, tenant review/evidence/review-pack resources, shared badge rendering, and capability helpers
|
||||||
- 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
|
- 260-governance-service-packaging: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing `TenantReviewComposer`, `TenantReviewSectionFactory`, `ComplianceEvidenceMappingV1`, `ReviewPackService`, `ArtifactTruthPresenter`, capability helpers, localization copy, and shared audit infrastructure
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|
||||||
### Pre-production compatibility check
|
### Pre-production compatibility check
|
||||||
|
|||||||
2
.github/skills/giteaflow/SKILL.md
vendored
2
.github/skills/giteaflow/SKILL.md
vendored
@ -5,4 +5,4 @@
|
|||||||
|
|
||||||
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
|
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
|
||||||
|
|
||||||
comit all changes, push to remote, and create a pull request against dev with gitea mcp
|
comit all changes, push to remote, and create a pull request against platform-dev with gitea mcp
|
||||||
92
.github/skills/spec-kit-next-best-prep/SKILL.md
vendored
92
.github/skills/spec-kit-next-best-prep/SKILL.md
vendored
@ -85,6 +85,9 @@ ## Hard Rules
|
|||||||
- Do not run destructive commands.
|
- Do not run destructive commands.
|
||||||
- Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
- Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||||
- Do not overwrite existing specs.
|
- Do not overwrite existing specs.
|
||||||
|
- Do not rewrite completed specs back into preparation state.
|
||||||
|
- Do not remove or normalize implementation history, close-out notes, validation results, completed task markers, smoke results, or post-implementation review language from completed specs.
|
||||||
|
- Treat completed-spec close-out and validation language as intentional repository history, not preparation drift.
|
||||||
- Do not move from preparation to an implementation step inside this skill.
|
- Do not move from preparation to an implementation step inside this skill.
|
||||||
|
|
||||||
## Required Inputs
|
## Required Inputs
|
||||||
@ -119,6 +122,32 @@ ## Required Repository Checks
|
|||||||
|
|
||||||
Do not edit application code.
|
Do not edit application code.
|
||||||
|
|
||||||
|
## Completed-Spec Guardrail
|
||||||
|
|
||||||
|
Before selecting an existing spec package as a `next-best-prep` target, explicitly check whether the spec is already completed, implementation-closed, or validated.
|
||||||
|
|
||||||
|
A spec must be treated as completed if any of the following signals are present in `spec.md`, `plan.md`, `tasks.md`, `quickstart.md`, checklist artifacts, or related Spec Kit package files:
|
||||||
|
|
||||||
|
- `Implementation Close-Out`
|
||||||
|
- `Implementation completed on`
|
||||||
|
- `Implementation Validation Results`
|
||||||
|
- `Implemented and validated`
|
||||||
|
- `Review Outcome` or `Implementation Review Outcome`
|
||||||
|
- passed validation, smoke, browser, or guardrail results
|
||||||
|
- completed task checklist markers for the implementation tasks
|
||||||
|
- post-implementation review or close-out language
|
||||||
|
- a status marker indicating implemented, completed, closed, or validated
|
||||||
|
|
||||||
|
If a spec is completed:
|
||||||
|
|
||||||
|
- exclude it from `next-best-prep` candidate selection
|
||||||
|
- do not patch, normalize, rewrite, or convert it back to preparation-only state
|
||||||
|
- do not remove close-out sections, validation results, completed task markers, smoke results, or post-implementation review language
|
||||||
|
- treat those artifacts as historical implementation evidence
|
||||||
|
- only use the completed spec as context for dependency or roadmap reasoning
|
||||||
|
|
||||||
|
If all high-priority candidates are already specced, active, or completed, stop and report `no safe next prep target` instead of modifying existing completed specs.
|
||||||
|
|
||||||
## Git and Branch Safety
|
## Git and Branch Safety
|
||||||
|
|
||||||
Before running any Spec Kit command:
|
Before running any Spec Kit command:
|
||||||
@ -143,6 +172,7 @@ ### Gate 1: Candidate Selection Gate
|
|||||||
|
|
||||||
- The selected candidate exists in roadmap/spec-candidate material or is directly provided by the user.
|
- 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 is not already covered by an existing active or completed spec.
|
||||||
|
- The selected target is not a completed spec package with implementation close-out, validation results, completed tasks, smoke results, or post-implementation review history.
|
||||||
- The selected candidate aligns with current roadmap priorities or explicitly documented product direction.
|
- 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.
|
- 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.
|
- Major adjacent concerns are listed as follow-up candidates instead of being hidden inside the primary scope.
|
||||||
@ -150,6 +180,7 @@ ### Gate 1: Candidate Selection Gate
|
|||||||
Fail behavior:
|
Fail behavior:
|
||||||
|
|
||||||
- If no candidate satisfies the gate, stop and report the top candidates plus the reason none is ready.
|
- If no candidate satisfies the gate, stop and report the top candidates plus the reason none is ready.
|
||||||
|
- If the only plausible targets are completed specs, stop and report `no safe next prep target`; do not modify those completed specs.
|
||||||
- Do not invent a new roadmap direction to force progress.
|
- Do not invent a new roadmap direction to force progress.
|
||||||
|
|
||||||
### Gate 2: Spec Readiness Gate
|
### Gate 2: Spec Readiness Gate
|
||||||
@ -180,6 +211,8 @@ ## Candidate Selection Rules
|
|||||||
- Read `docs/product/spec-candidates.md`.
|
- Read `docs/product/spec-candidates.md`.
|
||||||
- Read relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present.
|
- Read relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present.
|
||||||
- Check existing specs to avoid duplicates.
|
- Check existing specs to avoid duplicates.
|
||||||
|
- Check existing specs for completed-spec signals before selecting an existing package as a refresh target.
|
||||||
|
- Exclude completed specs from next-best-prep selection, even if their artifacts contain close-out, validation, or completed-task language that would look like drift in a preparation-only package.
|
||||||
- Prefer candidates that align with current roadmap priorities, platform foundations, enterprise UX, RBAC/isolation, auditability, observability, and governance workflow maturity.
|
- 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 candidates that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers.
|
||||||
- Prefer small, implementation-ready slices over broad platform rewrites.
|
- Prefer small, implementation-ready slices over broad platform rewrites.
|
||||||
@ -198,6 +231,7 @@ ## Candidate Selection Rules
|
|||||||
5. **Repo Readiness**: Does the repo already have enough structure to implement the next slice safely?
|
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?
|
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?
|
7. **User/Product Value**: Does it produce visible operator value or make the platform more sellable without heavy scope?
|
||||||
|
8. **Completion Safety**: Is the target genuinely unprepared or incomplete, rather than an already completed spec whose historical close-out artifacts should be preserved?
|
||||||
|
|
||||||
## Required Selection Output Before Spec Kit Execution
|
## Required Selection Output Before Spec Kit Execution
|
||||||
|
|
||||||
@ -208,6 +242,7 @@ ## Required Selection Output Before Spec Kit Execution
|
|||||||
- why it was selected
|
- why it was selected
|
||||||
- why close alternatives were deferred
|
- why close alternatives were deferred
|
||||||
- roadmap relationship
|
- roadmap relationship
|
||||||
|
- completed-spec check result for related existing specs
|
||||||
- smallest viable implementation slice
|
- smallest viable implementation slice
|
||||||
- proposed concise feature description to feed into `specify`
|
- proposed concise feature description to feed into `specify`
|
||||||
|
|
||||||
@ -296,7 +331,7 @@ ### Step 5: Run preparation `analyze`
|
|||||||
|
|
||||||
### Step 6: Fix preparation-artifact issues only
|
### Step 6: Fix preparation-artifact issues only
|
||||||
|
|
||||||
If preparation analyze finds issues, fix only Spec Kit preparation artifacts such as:
|
If preparation analyze finds issues, first confirm that the selected package is not completed. Then fix only Spec Kit preparation artifacts such as:
|
||||||
|
|
||||||
- `spec.md`
|
- `spec.md`
|
||||||
- `plan.md`
|
- `plan.md`
|
||||||
@ -322,6 +357,10 @@ ### Step 6: Fix preparation-artifact issues only
|
|||||||
- editing models, services, jobs, policies, Filament resources, Livewire components, tests, commands, routes, or views
|
- editing models, services, jobs, policies, Filament resources, Livewire components, tests, commands, routes, or views
|
||||||
- running implementation or test-fix loops
|
- running implementation or test-fix loops
|
||||||
- changing runtime behavior
|
- changing runtime behavior
|
||||||
|
- removing implementation close-out history from completed specs
|
||||||
|
- converting completed specs back to preparation-only wording
|
||||||
|
- changing passed validation or smoke results into planned validation commands
|
||||||
|
- unchecking completed implementation tasks in a completed spec
|
||||||
|
|
||||||
### Step 7: Evaluate the Spec Readiness Gate
|
### Step 7: Evaluate the Spec Readiness Gate
|
||||||
|
|
||||||
@ -478,23 +517,33 @@ ## Failure Handling
|
|||||||
2. Report the current branch and relevant uncommitted files.
|
2. Report the current branch and relevant uncommitted files.
|
||||||
3. Ask the user to commit, stash, or move to a clean worktree.
|
3. Ask the user to commit, stash, or move to a clean worktree.
|
||||||
|
|
||||||
|
If a completed spec is accidentally selected or modified:
|
||||||
|
|
||||||
|
1. Stop immediately.
|
||||||
|
2. Report that the selected spec is completed and therefore not a valid preparation target.
|
||||||
|
3. Revert only the changes made by this operation to that completed spec package, if they are isolated and safe to revert.
|
||||||
|
4. Run `git status --short` and report remaining changes.
|
||||||
|
5. Re-run candidate selection excluding completed specs.
|
||||||
|
6. If no safe unprepared candidate exists, report `no safe next prep target`.
|
||||||
|
|
||||||
## Final Response Requirements
|
## Final Response Requirements
|
||||||
|
|
||||||
Respond with:
|
Respond with:
|
||||||
|
|
||||||
1. Selected candidate and why it was chosen
|
1. Selected candidate and why it was chosen
|
||||||
2. Why close alternatives were deferred
|
2. Why close alternatives were deferred
|
||||||
3. Current branch after Spec Kit execution, if changed
|
3. Completed-spec guardrail result for related existing specs
|
||||||
4. Generated spec path
|
4. Current branch after Spec Kit execution, if changed
|
||||||
5. Files created or updated by Spec Kit
|
5. Generated spec path
|
||||||
6. Preparation analyze result summary
|
6. Files created or updated by Spec Kit
|
||||||
7. Preparation-artifact fixes applied after analyze
|
7. Preparation analyze result summary
|
||||||
8. Assumptions made
|
8. Preparation-artifact fixes applied after analyze
|
||||||
9. Open questions, if any
|
9. Assumptions made
|
||||||
10. Candidate Selection Gate result
|
10. Open questions, if any
|
||||||
11. Spec Readiness Gate result
|
11. Candidate Selection Gate result
|
||||||
12. Recommended next implementation prompt
|
12. Spec Readiness Gate result
|
||||||
13. Explicit statement that no application implementation was performed
|
13. Recommended next implementation prompt
|
||||||
|
14. Explicit statement that no application implementation was performed
|
||||||
|
|
||||||
Keep the final response concise, but include enough detail for the user to continue immediately.
|
Keep the final response concise, but include enough detail for the user to continue immediately.
|
||||||
|
|
||||||
@ -550,13 +599,14 @@ ## Example Invocation
|
|||||||
2. Check branch and working tree safety.
|
2. Check branch and working tree safety.
|
||||||
3. Compare candidate suitability.
|
3. Compare candidate suitability.
|
||||||
4. Select the next best candidate.
|
4. Select the next best candidate.
|
||||||
5. Evaluate the Candidate Selection Gate.
|
5. Exclude already completed specs from preparation or refresh targets, preserving their close-out and validation history.
|
||||||
6. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
|
6. Evaluate the Candidate Selection Gate.
|
||||||
7. Run the repository's real Spec Kit `plan` flow.
|
7. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
|
||||||
8. Run the repository's real Spec Kit `tasks` flow.
|
8. Run the repository's real Spec Kit `plan` flow.
|
||||||
9. Run the repository's real Spec Kit preparation `analyze` flow.
|
9. Run the repository's real Spec Kit `tasks` flow.
|
||||||
10. Fix analyze issues only in Spec Kit preparation artifacts.
|
10. Run the repository's real Spec Kit preparation `analyze` flow.
|
||||||
11. Evaluate the Spec Readiness Gate.
|
11. Fix analyze issues only in Spec Kit preparation artifacts.
|
||||||
12. Stop before application implementation.
|
12. Evaluate the Spec Readiness Gate.
|
||||||
13. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, gates, and next implementation prompt.
|
13. Stop before application implementation.
|
||||||
|
14. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, gates, and next implementation prompt.
|
||||||
```
|
```
|
||||||
@ -1,34 +1,35 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 2.10.0 -> 2.11.0
|
- Version change: 2.12.0 -> 2.13.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Expanded decision-first and operator-surface rules so operational,
|
- Expanded Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||||
governance, evidence, onboarding, review, and support-facing
|
so custom Filament UI must follow the canonical TenantPilot
|
||||||
detail/status surfaces separate decision content, operator
|
enterprise UI standard, must not introduce ad-hoc styling for
|
||||||
diagnostics, and support/raw evidence
|
cards, buttons, hovers, badges, icons, progress bars, empty states,
|
||||||
- Expanded review and enforcement expectations so specs, plans,
|
or interactive rows, and may only show interactive affordance when
|
||||||
tasks, and checklists must make audience modes, raw/support
|
a repo-real route/action and permitted capability exist
|
||||||
gating, one dominant next action, and duplicate-truth prevention
|
- Added sections: None
|
||||||
explicit
|
|
||||||
- Added sections:
|
|
||||||
- Audience-Aware Decision Surfaces & Disclosure Ladder
|
|
||||||
(DECIDE-AUD-001): requires customer-readable default paths,
|
|
||||||
operator diagnostics as progressive disclosure, support/raw
|
|
||||||
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 audience-aware disclosure
|
- .specify/templates/spec-template.md: require canonical UI-standard
|
||||||
section + constitution prompts ✅
|
compliance, no ad-hoc custom styling, and repo-real affordance
|
||||||
- .specify/templates/plan-template.md: add audience/disclosure
|
disclosure ✅
|
||||||
planning prompts + constitution checks ✅
|
- .specify/templates/plan-template.md: add UI-FIL-001 checks for the
|
||||||
- .specify/templates/tasks-template.md: add decision/disclosure
|
canonical UI standard and affordance honesty ✅
|
||||||
implementation + test tasks ✅
|
- .specify/templates/tasks-template.md: add implementation tasks for
|
||||||
- .specify/templates/checklist-template.md: add disclosure, raw-gating,
|
no ad-hoc styling and repo-real interactive affordances ✅
|
||||||
one-primary-action, and duplicate-truth review checks ✅
|
- .specify/templates/checklist-template.md: add explicit custom UI
|
||||||
- docs/product/standards/README.md: refresh constitution index for
|
standard and affordance review check ✅
|
||||||
the new audience-aware disclosure contract ✅
|
- docs/product/principles.md: align the high-level product rule with
|
||||||
|
the canonical UI standard ✅
|
||||||
|
- docs/product/standards/filament-native-enterprise-ui.md: align the
|
||||||
|
compact standard with the canonical UI source ✅
|
||||||
|
- docs/product/standards/README.md: index the canonical UI-standard
|
||||||
|
document ✅
|
||||||
|
- docs/HANDOVER.md: refresh the Filament standards summary ✅
|
||||||
|
- docs/ui/tenantpilot-enterprise-ui-standards.md: fix the
|
||||||
|
constitution-reference path to the canonical file ✅
|
||||||
- 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
|
||||||
@ -1710,6 +1711,7 @@ ### Badge Semantics Are Centralized (BADGE-001)
|
|||||||
|
|
||||||
### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
### 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.
|
||||||
|
- TenantPilot custom Filament UI MUST follow `docs/ui/tenantpilot-enterprise-ui-standards.md`. When this constitution gives a shorter rule, that document remains the canonical detailed standard for custom Filament affordance, styling, hierarchy, and disclosure patterns.
|
||||||
- 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
|
- Local Blade/Tailwind cards are allowed only when they preserve dark
|
||||||
@ -1717,6 +1719,39 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
|||||||
hierarchy, progressive disclosure, accessibility, and overall
|
hierarchy, progressive disclosure, accessibility, and overall
|
||||||
Filament visual language.
|
Filament visual language.
|
||||||
|
|
||||||
|
Enterprise consistency for custom surfaces
|
||||||
|
- TenantPilot custom Blade, Livewire widget, Filament page, and
|
||||||
|
productized dashboard/detail surfaces MUST preserve Filament-native
|
||||||
|
interaction semantics unless the governing spec records a bounded
|
||||||
|
product reason to diverge.
|
||||||
|
- Custom surfaces MUST NOT introduce independent button systems,
|
||||||
|
status color semantics, spacing systems, or card styles that
|
||||||
|
function as a parallel local design system.
|
||||||
|
- Feature specs and implementation MUST NOT introduce ad-hoc custom
|
||||||
|
styling for cards, buttons, hovers, badges, icons, progress bars,
|
||||||
|
empty states, or interactive rows.
|
||||||
|
- Each page, card cluster, or other focused action area MUST keep at
|
||||||
|
most one dominant primary action. Secondary actions MUST remain
|
||||||
|
neutral unless the action is destructive or the semantic state
|
||||||
|
change is itself the point of the action.
|
||||||
|
- Status, health, risk, readiness, and similar state cues MUST be
|
||||||
|
conveyed through BADGE-001 badges, labels, chips, and supporting
|
||||||
|
text rather than arbitrary button colors or per-card custom action
|
||||||
|
styling.
|
||||||
|
- Hover, pointer, focus, shadow, or similar interactive affordance MUST
|
||||||
|
appear only when a repo-real route/action exists and the current
|
||||||
|
actor has the permitted capability. Otherwise the surface MUST render
|
||||||
|
as static and non-interactive.
|
||||||
|
- Custom Blade/Tailwind composition MUST be used to arrange
|
||||||
|
product-specific layout, decision hierarchy, and progressive
|
||||||
|
disclosure, not to redefine semantic action, status, or container
|
||||||
|
primitives that Filament or shared project primitives already
|
||||||
|
standardize.
|
||||||
|
- Per-card custom action styling, status-colored non-status actions,
|
||||||
|
and oversized custom borders, shadows, or spacing that visually
|
||||||
|
detach a surface from the Filament panel are forbidden unless
|
||||||
|
UI-EX-001 records a bounded exception.
|
||||||
|
|
||||||
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
|
||||||
Filament-native components or approved shared primitives.
|
Filament-native components or approved shared primitives.
|
||||||
@ -1835,4 +1870,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 2.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-27
|
**Version**: 2.13.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-05-03
|
||||||
|
|||||||
@ -22,6 +22,7 @@ ## Applicability And Low-Impact Gate
|
|||||||
## Native, Shared-Family, And State Ownership
|
## Native, Shared-Family, And State Ownership
|
||||||
|
|
||||||
- [ ] CHK003 The surface remains native/shared-primitives first; fake-native controls, GET-form page-body interactions, and simple-overview replacements are not treated as harmless customization.
|
- [ ] CHK003 The surface remains native/shared-primitives first; fake-native controls, GET-form page-body interactions, and simple-overview replacements are not treated as harmless customization.
|
||||||
|
- [ ] CHK028 Custom Blade, Livewire widget, and dashboard/detail surfaces follow `docs/ui/tenantpilot-enterprise-ui-standards.md`: they do not invent an independent button, status-color, spacing, or card system, they do not add ad-hoc styling for cards/buttons/hovers/badges/icons/progress bars/empty states/interactive rows, status stays badge/label/supporting-text first, each focused area keeps one dominant primary action, and interactive affordance exists only when a repo-real route/action and permitted capability exist.
|
||||||
- [ ] CHK004 Any shared-detail or shared-family surface keeps one shared contract, and any host variation is either folded back into that contract or explicitly bounded as an exception.
|
- [ ] CHK004 Any shared-detail or shared-family surface keeps one shared contract, and any host variation is either folded back into that contract or explicitly bounded as an exception.
|
||||||
- [ ] CHK005 Shell, page, detail, and URL/query state owners are named once and do not collapse into one another.
|
- [ ] CHK005 Shell, page, detail, and URL/query state owners are named once and do not collapse into one another.
|
||||||
- [ ] CHK006 The likely next operator action and the primary inspect/open model stay coherent with the declared surface class.
|
- [ ] CHK006 The likely next operator action and the primary inspect/open model stay coherent with the declared surface class.
|
||||||
|
|||||||
@ -115,10 +115,21 @@ ## 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): custom Filament UI follows `docs/ui/tenantpilot-enterprise-ui-standards.md`; no ad-hoc custom styling for cards, buttons, hovers, badges, icons, progress bars, empty states, or interactive rows
|
||||||
- Filament-native UI (UI-FIL-001): if local Blade/Tailwind cards are
|
- Filament-native UI (UI-FIL-001): if local Blade/Tailwind cards are
|
||||||
still necessary, they preserve dark mode correctness, spacing
|
still necessary, they preserve dark mode correctness, spacing
|
||||||
consistency, badge semantics, action hierarchy, progressive
|
consistency, badge semantics, action hierarchy, progressive
|
||||||
disclosure, accessibility, and Filament visual language
|
disclosure, accessibility, and Filament visual language
|
||||||
|
- Filament-native UI (UI-FIL-001): custom Blade/Widget/Page surfaces
|
||||||
|
keep Filament-native interaction semantics, preserve one dominant
|
||||||
|
primary action per focused area, express state through
|
||||||
|
BADGE-001-aligned badges/labels/supporting text instead of arbitrary
|
||||||
|
button colors, and do not create independent button/card/spacing
|
||||||
|
systems
|
||||||
|
- Filament-native UI (UI-FIL-001): hover, pointer, focus, shadow, or
|
||||||
|
similar interactive affordance appears only when a repo-real
|
||||||
|
route/action and permitted capability exist; non-interactive rows
|
||||||
|
stay visibly static
|
||||||
- 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,
|
||||||
|
|||||||
@ -325,10 +325,16 @@ ## Requirements *(mandatory)*
|
|||||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||||
|
|
||||||
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
|
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
|
||||||
|
- how the affected surface follows `docs/ui/tenantpilot-enterprise-ui-standards.md`,
|
||||||
- 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,
|
- how the feature avoids ad-hoc custom styling for cards, buttons, hovers, badges, icons, progress bars, empty states, and interactive rows,
|
||||||
|
- how any custom Blade, Livewire widget, page, or dashboard surface preserves Filament-native interaction semantics and avoids introducing an independent button, status-color, spacing, or card system,
|
||||||
|
- how each affected page or focused action area keeps at most one dominant primary action and keeps secondary actions neutral unless they are destructive or the semantic state change is the point of the action,
|
||||||
|
- how status is conveyed through BADGE-001 badges, labels, chips, or supporting text rather than arbitrary button colors or per-card custom action styling,
|
||||||
|
- how hover, pointer, focus, shadow, or similar interactive affordance is used only when a repo-real route/action and permitted capability exist, and how non-interactive rows remain visibly static,
|
||||||
|
- 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 are used to compose product-specific layout rather than a parallel local design system,
|
||||||
- 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,
|
||||||
|
|||||||
@ -132,8 +132,24 @@ # Tasks: [FEATURE NAME]
|
|||||||
- preventing empty `ActionGroup` / `BulkActionGroup` placeholders,
|
- preventing empty `ActionGroup` / `BulkActionGroup` placeholders,
|
||||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||||
- adding `AuditLog` entries for relevant mutations,
|
- adding `AuditLog` entries for relevant mutations,
|
||||||
|
- following `docs/ui/tenantpilot-enterprise-ui-standards.md` for any
|
||||||
|
custom Filament UI surface,
|
||||||
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
|
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
|
||||||
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
|
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
|
||||||
|
- avoiding ad-hoc styling for cards, buttons, hovers, badges, icons,
|
||||||
|
progress bars, empty states, and interactive rows,
|
||||||
|
- keeping any custom Blade, Livewire widget, page, or
|
||||||
|
dashboard/detail surface Filament-native in semantics: no
|
||||||
|
independent button, status-color, card, or spacing system, one
|
||||||
|
dominant primary action per focused area, and secondary actions
|
||||||
|
neutral unless destructive or explicitly state-changing,
|
||||||
|
- expressing status, health, risk, readiness, and similar cues through
|
||||||
|
BADGE-001 badges, labels, chips, and supporting text rather than
|
||||||
|
arbitrary button colors or per-card custom action styling,
|
||||||
|
- using hover, pointer, focus, shadow, or similar interactive
|
||||||
|
affordance only when a repo-real route/action and permitted
|
||||||
|
capability exist, and rendering static rows without fake
|
||||||
|
interactivity otherwise,
|
||||||
- documenting any workflow-hub, wizard, utility/system, or other
|
- documenting any workflow-hub, wizard, utility/system, or other
|
||||||
special-type exception in the spec/PR and adding dedicated test
|
special-type exception in the spec/PR and adding dedicated test
|
||||||
coverage,
|
coverage,
|
||||||
|
|||||||
@ -50,7 +50,7 @@ public function handle(): int
|
|||||||
|
|
||||||
$changedVersions = 0;
|
$changedVersions = 0;
|
||||||
$changedPolicies = 0;
|
$changedPolicies = 0;
|
||||||
$ignoredPolicies = 0;
|
$providerMissingPolicies = 0;
|
||||||
|
|
||||||
foreach ($candidates as $policy) {
|
foreach ($candidates as $policy) {
|
||||||
$latestVersion = $policy->versions()->latest('version_number')->first();
|
$latestVersion = $policy->versions()->latest('version_number')->first();
|
||||||
@ -86,14 +86,15 @@ public function handle(): int
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existingTarget) {
|
if ($existingTarget) {
|
||||||
$policy->forceFill(['ignored_at' => now()])->save();
|
$policy->forceFill(['missing_from_provider_at' => now()])->save();
|
||||||
$ignoredPolicies++;
|
$providerMissingPolicies++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$policy->forceFill([
|
$policy->forceFill([
|
||||||
'policy_type' => 'windowsEnrollmentStatusPage',
|
'policy_type' => 'windowsEnrollmentStatusPage',
|
||||||
|
'missing_from_provider_at' => null,
|
||||||
])->save();
|
])->save();
|
||||||
$changedPolicies++;
|
$changedPolicies++;
|
||||||
|
|
||||||
@ -106,7 +107,7 @@ public function handle(): int
|
|||||||
$this->info('Done.');
|
$this->info('Done.');
|
||||||
$this->info('PolicyVersions changed: '.$changedVersions);
|
$this->info('PolicyVersions changed: '.$changedVersions);
|
||||||
$this->info('Policies changed: '.$changedPolicies);
|
$this->info('Policies changed: '.$changedPolicies);
|
||||||
$this->info('Policies ignored: '.$ignoredPolicies);
|
$this->info('Policies marked provider-missing: '.$providerMissingPolicies);
|
||||||
$this->info('Mode: '.($dryRun ? 'dry-run' : 'write'));
|
$this->info('Mode: '.($dryRun ? 'dry-run' : 'write'));
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
|
|||||||
@ -12,8 +12,13 @@
|
|||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
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\PortfolioCompare\CrossTenantPromotionExecutionService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||||
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
|
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
|
||||||
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
||||||
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
|
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
|
||||||
@ -23,13 +28,16 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use DomainException;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
use Filament\Forms\Contracts\HasForms;
|
use Filament\Forms\Contracts\HasForms;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas\Components\Grid;
|
use Filament\Schemas\Components\Grid;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
|
use InvalidArgumentException;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -192,6 +200,7 @@ protected function getHeaderActions(): array
|
|||||||
->label('Generate promotion preflight')
|
->label('Generate promotion preflight')
|
||||||
->icon('heroicon-o-sparkles')
|
->icon('heroicon-o-sparkles')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
|
->visible(fn (): bool => ! is_array($this->preflight))
|
||||||
->disabled(fn (): bool => $this->preflightDisabledReason() !== null)
|
->disabled(fn (): bool => $this->preflightDisabledReason() !== null)
|
||||||
->tooltip(fn (): ?string => $this->preflightDisabledReason())
|
->tooltip(fn (): ?string => $this->preflightDisabledReason())
|
||||||
->action(fn (): mixed => $this->generatePromotionPreflight());
|
->action(fn (): mixed => $this->generatePromotionPreflight());
|
||||||
@ -201,6 +210,7 @@ protected function getHeaderActions(): array
|
|||||||
fn (): ?Workspace => $this->workspace(),
|
fn (): ?Workspace => $this->workspace(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
->preserveDisabled()
|
->preserveDisabled()
|
||||||
->tooltip('You need workspace baseline manage access to generate a promotion preflight.')
|
->tooltip('You need workspace baseline manage access to generate a promotion preflight.')
|
||||||
->apply()
|
->apply()
|
||||||
@ -223,6 +233,19 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
$actions[] = $preflightAction;
|
$actions[] = $preflightAction;
|
||||||
|
|
||||||
|
$actions[] = Action::make('executePromotion')
|
||||||
|
->label('Execute promotion')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->color('warning')
|
||||||
|
->visible(fn (): bool => is_array($this->preflight))
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Execute promotion')
|
||||||
|
->modalDescription(fn (): string => $this->executePromotionConfirmationDescription())
|
||||||
|
->modalSubmitActionLabel('Queue promotion')
|
||||||
|
->disabled(fn (): bool => $this->executePromotionDisabledReason() !== null)
|
||||||
|
->tooltip(fn (): ?string => $this->executePromotionDisabledReason())
|
||||||
|
->action(fn (): mixed => $this->executePromotion());
|
||||||
|
|
||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -282,6 +305,74 @@ public function generatePromotionPreflight(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function executePromotion(): void
|
||||||
|
{
|
||||||
|
$this->authorizePageAccess();
|
||||||
|
$this->authorizePromotionExecution();
|
||||||
|
|
||||||
|
if (! is_array($this->preview) || ! is_array($this->preflight)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Promotion execution unavailable')
|
||||||
|
->body('Generate a current promotion preflight before executing promotion.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selection = $this->compareSelection();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $selection instanceof CrossTenantCompareSelection || ! $user instanceof User) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Promotion execution unavailable')
|
||||||
|
->body('Refresh the compare selection before executing promotion.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = app(CrossTenantPromotionExecutionService::class)->start(
|
||||||
|
selection: $selection,
|
||||||
|
preview: $this->preview,
|
||||||
|
preflight: $this->preflight,
|
||||||
|
actor: $user,
|
||||||
|
);
|
||||||
|
} catch (OperationalControlBlockedException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->title($exception->title())
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (DomainException|InvalidArgumentException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Promotion execution unavailable')
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
||||||
|
result: $result,
|
||||||
|
blockedTitle: 'Promotion execution blocked',
|
||||||
|
runUrl: OperationRunLinks::tenantlessView($result->run),
|
||||||
|
scopeBusyTitle: 'Promotion scope busy',
|
||||||
|
scopeBusyBody: 'Another promotion or restore operation is already active for this target scope. Open the active operation for progress and next steps.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification->send();
|
||||||
|
}
|
||||||
|
|
||||||
public function clearSelectionUrl(): string
|
public function clearSelectionUrl(): string
|
||||||
{
|
{
|
||||||
return static::getUrl($this->routeParameters([
|
return static::getUrl($this->routeParameters([
|
||||||
@ -453,6 +544,30 @@ private function authorizePreflightExecution(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function authorizePromotionExecution(): void
|
||||||
|
{
|
||||||
|
$this->authorizePreflightExecution();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetTenant = $this->selectedTargetTenant();
|
||||||
|
|
||||||
|
if (! $targetTenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function compareSelection(): ?CrossTenantCompareSelection
|
private function compareSelection(): ?CrossTenantCompareSelection
|
||||||
{
|
{
|
||||||
$sourceTenant = $this->selectedSourceTenant();
|
$sourceTenant = $this->selectedSourceTenant();
|
||||||
@ -593,6 +708,73 @@ private function preflightDisabledReason(): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function executePromotionDisabledReason(): ?string
|
||||||
|
{
|
||||||
|
if ($this->selectionMessage !== null) {
|
||||||
|
return $this->selectionMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($this->preview)) {
|
||||||
|
return 'Run compare preview before executing promotion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($this->preflight)) {
|
||||||
|
return 'Generate a current promotion preflight before executing promotion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) data_get($this->preflight, 'summary.ready', 0) <= 0) {
|
||||||
|
return 'Current promotion preflight has no ready governed subjects to execute.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if ($user instanceof User && $workspace instanceof Workspace) {
|
||||||
|
/** @var WorkspaceCapabilityResolver $workspaceResolver */
|
||||||
|
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if ($workspaceResolver->isMember($user, $workspace)
|
||||||
|
&& ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
||||||
|
return 'You need workspace baseline manage access to execute promotion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetTenant = $this->selectedTargetTenant();
|
||||||
|
|
||||||
|
if ($targetTenant instanceof Tenant) {
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_MANAGE)) {
|
||||||
|
return 'You need target tenant manage access to execute promotion.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function executePromotionConfirmationDescription(): string
|
||||||
|
{
|
||||||
|
$selection = $this->compareSelection();
|
||||||
|
$ready = (int) data_get($this->preflight, 'summary.ready', 0);
|
||||||
|
$blocked = (int) data_get($this->preflight, 'summary.blocked', 0);
|
||||||
|
$manualMappingRequired = (int) data_get($this->preflight, 'summary.manual_mapping_required', 0);
|
||||||
|
$excluded = $blocked + $manualMappingRequired;
|
||||||
|
|
||||||
|
$sourceTenantName = $selection?->sourceTenant->name ?? 'Source tenant';
|
||||||
|
$targetTenantName = $selection?->targetTenant->name ?? 'Target tenant';
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Queue one promotion run from %s to %s for %d ready governed subject%s. %d subject%s remain excluded on the compare page.',
|
||||||
|
$sourceTenantName,
|
||||||
|
$targetTenantName,
|
||||||
|
$ready,
|
||||||
|
$ready === 1 ? '' : 's',
|
||||||
|
$excluded,
|
||||||
|
$excluded === 1 ? '' : 's',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param mixed $value
|
* @param mixed $value
|
||||||
*/
|
*/
|
||||||
|
|||||||
719
apps/platform/app/Filament/Pages/Governance/DecisionRegister.php
Normal file
719
apps/platform/app/Filament/Pages/Governance/DecisionRegister.php
Normal file
@ -0,0 +1,719 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Governance;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
|
use App\Models\FindingException;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\GovernanceDecisions\GovernanceDecisionRegisterBuilder;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class DecisionRegister extends Page implements HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Decision register';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 6;
|
||||||
|
|
||||||
|
protected static ?string $title = 'Decision register';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'governance/decisions';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.governance.decision-register';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, Tenant>|null
|
||||||
|
*/
|
||||||
|
private ?array $authorizedTenants = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, Tenant>|null
|
||||||
|
*/
|
||||||
|
private ?array $visibleDecisionTenants = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private ?array $registerPayload = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private ?array $unfilteredRegisterPayload = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, array<string, mixed>>|null
|
||||||
|
*/
|
||||||
|
private ?array $rowPayloadByExceptionId = null;
|
||||||
|
|
||||||
|
private ?Workspace $workspace = null;
|
||||||
|
|
||||||
|
public ?int $tenantId = null;
|
||||||
|
|
||||||
|
public string $registerState = 'open';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header controls keep tenant and register-state scope visible without introducing a second mutation surface.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The decision register keeps one dominant row action and avoids a More menu in v1.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The decision register is read-only and intentionally omits bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Filtered empty states stay truthful and provide one path back to the broader register scope.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'The register owns no local detail surface; existing exception detail remains the action owner.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = static::resolveWorkspaceFromRequest();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $workspace)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (static::hasRequestedTenantPrefilter()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$visibleTenants = static::resolveVisibleDecisionTenantsFor($user, $workspace);
|
||||||
|
|
||||||
|
if ($visibleTenants === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request()->query('register_state') === 'recently_closed') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) (app(GovernanceDecisionRegisterBuilder::class)->build(
|
||||||
|
workspace: $workspace,
|
||||||
|
visibleTenants: $visibleTenants,
|
||||||
|
registerState: 'open',
|
||||||
|
)['counts']['open'] ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->mountInteractsWithTable();
|
||||||
|
$this->authorizeWorkspaceMembership();
|
||||||
|
$this->applyRequestedTenantPrefilter();
|
||||||
|
$this->registerState = $this->resolveRequestedRegisterState();
|
||||||
|
$this->ensureRegisterIsVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pageUrl(array $overrides = []): string
|
||||||
|
{
|
||||||
|
$selectedTenant = $this->selectedTenant();
|
||||||
|
$resolvedTenant = array_key_exists('tenant', $overrides)
|
||||||
|
? $overrides['tenant']
|
||||||
|
: ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null);
|
||||||
|
$resolvedRegisterState = array_key_exists('register_state', $overrides)
|
||||||
|
? $overrides['register_state']
|
||||||
|
: $this->registerState;
|
||||||
|
|
||||||
|
return static::getUrl(
|
||||||
|
panel: 'admin',
|
||||||
|
parameters: array_filter([
|
||||||
|
'tenant_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
|
||||||
|
'register_state' => is_string($resolvedRegisterState) && $resolvedRegisterState !== 'open' ? $resolvedRegisterState : null,
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function appliedScope(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'workspace_label' => $this->workspace()?->name,
|
||||||
|
'tenant_label' => $this->selectedTenant()?->name,
|
||||||
|
'register_state_label' => $this->registerStateLabel($this->registerState),
|
||||||
|
'visible_count' => $this->registerPayload()['counts'][$this->registerState] ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{key: string, label: string, count: int}>
|
||||||
|
*/
|
||||||
|
public function availableRegisterStates(): array
|
||||||
|
{
|
||||||
|
$counts = $this->registerPayload()['counts'] ?? ['open' => 0, 'recently_closed' => 0];
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'key' => 'open',
|
||||||
|
'label' => 'Open decisions',
|
||||||
|
'count' => (int) ($counts['open'] ?? 0),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'recently_closed',
|
||||||
|
'label' => 'Recently closed',
|
||||||
|
'count' => (int) ($counts['recently_closed'] ?? 0),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasTenantPrefilter(): bool
|
||||||
|
{
|
||||||
|
return $this->selectedTenant() instanceof Tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isActiveRegisterState(string $registerState): bool
|
||||||
|
{
|
||||||
|
return $this->registerState === $registerState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function emptyStateHeading(): string
|
||||||
|
{
|
||||||
|
if ($this->tenantFilterAloneExcludesRows()) {
|
||||||
|
return 'This tenant filter is hiding other visible decision follow-through';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->registerState === 'recently_closed') {
|
||||||
|
return 'No recently closed decisions match this filter right now.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'No open decisions match this filter right now.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function emptyStateDescription(): string
|
||||||
|
{
|
||||||
|
if ($this->tenantFilterAloneExcludesRows()) {
|
||||||
|
return 'The current tenant scope is calm, but other visible tenants in this workspace still have open governance decisions.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->registerState === 'recently_closed') {
|
||||||
|
return 'Switch back to open decisions to continue the current follow-through lane, or widen the tenant scope if you were filtering the register.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Try widening the tenant scope or switch to recently closed decisions if you are checking what was just finished.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function emptyStateActionLabel(): ?string
|
||||||
|
{
|
||||||
|
if ($this->tenantFilterAloneExcludesRows()) {
|
||||||
|
return 'Clear tenant filter';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->registerState === 'recently_closed') {
|
||||||
|
return 'Open current decisions';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function emptyStateActionUrl(): ?string
|
||||||
|
{
|
||||||
|
if ($this->tenantFilterAloneExcludesRows()) {
|
||||||
|
return $this->pageUrl(['tenant' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->registerState === 'recently_closed') {
|
||||||
|
return $this->pageUrl(['register_state' => 'open']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query($this->tableQuery())
|
||||||
|
->defaultSort('review_due_at', 'asc')
|
||||||
|
->paginated(TablePaginationProfiles::resource())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
|
->recordUrl(null)
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('tenant.name')
|
||||||
|
->label('Tenant')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('current_validity_state')
|
||||||
|
->label('Impact')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
||||||
|
TextColumn::make('owner.name')
|
||||||
|
->label('Owner')
|
||||||
|
->placeholder('—')
|
||||||
|
->toggleable(),
|
||||||
|
TextColumn::make('review_due_at')
|
||||||
|
->label('Review due')
|
||||||
|
->dateTime()
|
||||||
|
->since()
|
||||||
|
->placeholder('—')
|
||||||
|
->tooltip(fn (FindingException $record): ?string => $record->review_due_at?->toDayDateTimeString())
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('proof_availability')
|
||||||
|
->label('Proof')
|
||||||
|
->state(function (FindingException $record): string {
|
||||||
|
$referenceCount = (int) data_get($record->evidence_summary ?? [], 'reference_count', 0);
|
||||||
|
|
||||||
|
return $referenceCount > 0
|
||||||
|
? $referenceCount.' evidence linked'
|
||||||
|
: 'No linked proof';
|
||||||
|
})
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('next_action_label')
|
||||||
|
->label('Next action')
|
||||||
|
->state(fn (FindingException $record): ?string => $this->rowPayload($record)['next_action_label'] ?? null)
|
||||||
|
->visible(fn (): bool => $this->registerState === 'open')
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('closure_reason')
|
||||||
|
->label('Closure reason')
|
||||||
|
->state(fn (FindingException $record): ?string => $this->rowPayload($record)['closure_reason'] ?? null)
|
||||||
|
->placeholder('—')
|
||||||
|
->visible(fn (): bool => $this->registerState === 'recently_closed')
|
||||||
|
->wrap(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Action::make('open_decision')
|
||||||
|
->label('Open decision')
|
||||||
|
->color('gray')
|
||||||
|
->url(fn (FindingException $record): ?string => $this->decisionUrl($record)),
|
||||||
|
])
|
||||||
|
->emptyStateHeading($this->emptyStateHeading())
|
||||||
|
->emptyStateDescription($this->emptyStateDescription())
|
||||||
|
->emptyStateActions($this->emptyStateActions());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<Tables\Actions\Action>
|
||||||
|
*/
|
||||||
|
private function emptyStateActions(): array
|
||||||
|
{
|
||||||
|
$label = $this->emptyStateActionLabel();
|
||||||
|
$url = $this->emptyStateActionUrl();
|
||||||
|
|
||||||
|
if (! is_string($label) || ! is_string($url)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
Action::make('empty_state_scope_action')
|
||||||
|
->label($label)
|
||||||
|
->url($url),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Builder<FindingException>
|
||||||
|
*/
|
||||||
|
private function tableQuery(): Builder
|
||||||
|
{
|
||||||
|
$tenantIds = array_values(array_map(
|
||||||
|
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||||
|
$this->currentScopeTenants(),
|
||||||
|
));
|
||||||
|
|
||||||
|
$query = FindingException::query()
|
||||||
|
->where('workspace_id', (int) $this->workspace()?->getKey())
|
||||||
|
->whereIn('tenant_id', $tenantIds)
|
||||||
|
->with(['tenant', 'owner', 'currentDecision']);
|
||||||
|
|
||||||
|
if ($this->registerState === 'recently_closed') {
|
||||||
|
return $query
|
||||||
|
->whereIn('status', [
|
||||||
|
FindingException::STATUS_REJECTED,
|
||||||
|
FindingException::STATUS_REVOKED,
|
||||||
|
FindingException::STATUS_SUPERSEDED,
|
||||||
|
])
|
||||||
|
->whereHas('currentDecision', function (Builder $decisionQuery): void {
|
||||||
|
$decisionQuery->where('decided_at', '>=', now()->startOfDay()->subDays(30));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->whereNotIn('status', [
|
||||||
|
FindingException::STATUS_REJECTED,
|
||||||
|
FindingException::STATUS_REVOKED,
|
||||||
|
FindingException::STATUS_SUPERSEDED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeWorkspaceMembership(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $workspace)) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureRegisterIsVisible(): void
|
||||||
|
{
|
||||||
|
if ($this->visibleDecisionTenants() === []) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->tenantId !== null || $this->registerState !== 'open') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($this->registerPayload()['counts']['open'] ?? 0) === 0) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Tenant>
|
||||||
|
*/
|
||||||
|
private 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 = static::resolveAuthorizedTenantsFor($user, $workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Tenant>
|
||||||
|
*/
|
||||||
|
private function visibleDecisionTenants(): array
|
||||||
|
{
|
||||||
|
if ($this->visibleDecisionTenants !== null) {
|
||||||
|
return $this->visibleDecisionTenants;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
$tenants = $this->authorizedTenants();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $workspace instanceof Workspace || $tenants === []) {
|
||||||
|
return $this->visibleDecisionTenants = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->visibleDecisionTenants = static::resolveVisibleDecisionTenantsFor($user, $workspace, $tenants);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyRequestedTenantPrefilter(): void
|
||||||
|
{
|
||||||
|
$requestedTenant = request()->query('tenant_id', request()->query('tenant'));
|
||||||
|
|
||||||
|
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->tenantId = (int) $tenant->getKey();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveRequestedRegisterState(): string
|
||||||
|
{
|
||||||
|
$registerState = request()->query('register_state');
|
||||||
|
|
||||||
|
if (! is_string($registerState)) {
|
||||||
|
return 'open';
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($registerState, ['open', 'recently_closed'], true)
|
||||||
|
? $registerState
|
||||||
|
: 'open';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hasRequestedTenantPrefilter(): bool
|
||||||
|
{
|
||||||
|
$requestedTenant = request()->query('tenant_id', request()->query('tenant'));
|
||||||
|
|
||||||
|
return is_string($requestedTenant) || is_numeric($requestedTenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveWorkspaceFromRequest(): ?Workspace
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! is_int($workspaceId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Tenant>
|
||||||
|
*/
|
||||||
|
private static function resolveAuthorizedTenantsFor(User $user, Workspace $workspace): array
|
||||||
|
{
|
||||||
|
return $user->tenants()
|
||||||
|
->where('tenants.workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('tenants.status', 'active')
|
||||||
|
->orderBy('tenants.name')
|
||||||
|
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, Tenant>|null $authorizedTenants
|
||||||
|
* @return array<int, Tenant>
|
||||||
|
*/
|
||||||
|
private static function resolveVisibleDecisionTenantsFor(User $user, Workspace $workspace, ?array $authorizedTenants = null): array
|
||||||
|
{
|
||||||
|
$tenants = $authorizedTenants ?? static::resolveAuthorizedTenantsFor($user, $workspace);
|
||||||
|
|
||||||
|
if ($tenants === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
$resolver->primeMemberships(
|
||||||
|
$user,
|
||||||
|
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$tenants,
|
||||||
|
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::FINDING_EXCEPTION_VIEW),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function workspace(): ?Workspace
|
||||||
|
{
|
||||||
|
if ($this->workspace instanceof Workspace) {
|
||||||
|
return $this->workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->workspace = static::resolveWorkspaceFromRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function selectedTenant(): ?Tenant
|
||||||
|
{
|
||||||
|
if (! is_int($this->tenantId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->visibleDecisionTenants() as $tenant) {
|
||||||
|
if ((int) $tenant->getKey() === $this->tenantId) {
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Tenant>
|
||||||
|
*/
|
||||||
|
private function currentScopeTenants(): array
|
||||||
|
{
|
||||||
|
$selectedTenant = $this->selectedTenant();
|
||||||
|
|
||||||
|
if ($selectedTenant instanceof Tenant) {
|
||||||
|
return [$selectedTenant];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->visibleDecisionTenants();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function registerPayload(): array
|
||||||
|
{
|
||||||
|
if (is_array($this->registerPayload)) {
|
||||||
|
return $this->registerPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $this->registerPayload = [
|
||||||
|
'rows' => [],
|
||||||
|
'counts' => ['open' => 0, 'recently_closed' => 0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->registerPayload = app(GovernanceDecisionRegisterBuilder::class)->build(
|
||||||
|
workspace: $workspace,
|
||||||
|
visibleTenants: $this->currentScopeTenants(),
|
||||||
|
registerState: $this->registerState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function unfilteredRegisterPayload(): array
|
||||||
|
{
|
||||||
|
if (is_array($this->unfilteredRegisterPayload)) {
|
||||||
|
return $this->unfilteredRegisterPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $this->unfilteredRegisterPayload = [
|
||||||
|
'rows' => [],
|
||||||
|
'counts' => ['open' => 0, 'recently_closed' => 0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->unfilteredRegisterPayload = app(GovernanceDecisionRegisterBuilder::class)->build(
|
||||||
|
workspace: $workspace,
|
||||||
|
visibleTenants: $this->visibleDecisionTenants(),
|
||||||
|
registerState: 'open',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function rowPayload(FindingException $record): array
|
||||||
|
{
|
||||||
|
if (! is_array($this->rowPayloadByExceptionId)) {
|
||||||
|
$this->rowPayloadByExceptionId = collect($this->registerPayload()['rows'] ?? [])
|
||||||
|
->keyBy('exception_id')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->rowPayloadByExceptionId[(int) $record->getKey()] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenantFilterAloneExcludesRows(): bool
|
||||||
|
{
|
||||||
|
if (! is_int($this->tenantId) || $this->registerState !== 'open') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($this->registerPayload()['rows'] ?? []) !== []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ($this->unfilteredRegisterPayload()['counts']['open'] ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registerStateLabel(string $registerState): string
|
||||||
|
{
|
||||||
|
return match ($registerState) {
|
||||||
|
'recently_closed' => 'Recently closed',
|
||||||
|
default => 'Open decisions',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decisionUrl(FindingException $record): ?string
|
||||||
|
{
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->appendQuery(
|
||||||
|
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant),
|
||||||
|
$this->navigationContext()->toQuery(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function navigationContext(): CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
return CanonicalNavigationContext::forDecisionRegister(
|
||||||
|
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
|
||||||
|
tenantId: $this->tenantId,
|
||||||
|
backLinkUrl: $this->pageUrl(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $query
|
||||||
|
*/
|
||||||
|
private function appendQuery(string $url, array $query): string
|
||||||
|
{
|
||||||
|
$queryString = http_build_query($query);
|
||||||
|
|
||||||
|
if ($queryString === '') {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$separator = str_contains($url, '?') ? '&' : '?';
|
||||||
|
|
||||||
|
return $url.$separator.$queryString;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,18 +5,23 @@
|
|||||||
namespace App\Filament\Pages\Reviews;
|
namespace App\Filament\Pages\Reviews;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Models\FindingException;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
|
use App\Models\Tenant;
|
||||||
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\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
use App\Support\Findings\FindingOutcomeSemantics;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
|
use App\Support\TenantReviewCompletenessState;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -36,6 +41,7 @@
|
|||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -45,7 +51,7 @@ class CustomerReviewWorkspace extends Page implements HasTable
|
|||||||
|
|
||||||
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
|
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
|
||||||
|
|
||||||
private const string SOURCE_SURFACE = 'customer_review_workspace';
|
public const string SOURCE_SURFACE = 'customer_review_workspace';
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
@ -67,10 +73,10 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the customer review workspace.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the customer review workspace.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::PrimaryLinkColumn->value)
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The customer review workspace remains scan-first and does not expose bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The customer review workspace remains scan-first and does not expose bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA when filters are active.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA when filters are active.')
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the latest published review detail instead of an inline canonical detail panel.');
|
->exempt(ActionSurfaceSlot::DetailHeader, 'The dedicated open link column opens the latest published review detail instead of an inline canonical detail panel.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getNavigationGroup(): string
|
public static function getNavigationGroup(): string
|
||||||
@ -109,6 +115,7 @@ public function mount(): void
|
|||||||
$this->authorizePageAccess();
|
$this->authorizePageAccess();
|
||||||
$this->applyRequestedTenantPrefilter();
|
$this->applyRequestedTenantPrefilter();
|
||||||
$this->mountInteractsWithTable();
|
$this->mountInteractsWithTable();
|
||||||
|
$this->auditWorkspaceOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
@ -146,34 +153,41 @@ public function table(Table $table): Table
|
|||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
->recordUrl(null)
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable()->sortable(),
|
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable(),
|
||||||
|
TextColumn::make('package_availability')
|
||||||
|
->label(__('localization.review.governance_package'))
|
||||||
|
->width('9rem')
|
||||||
|
->extraHeaderAttributes(['class' => 'whitespace-normal'])
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (Tenant $record): string => $this->governancePackageAvailabilityLabel($record))
|
||||||
|
->color(fn (Tenant $record): string => $this->governancePackageAvailabilityColor($record))
|
||||||
|
->tooltip(fn (Tenant $record): string => $this->governancePackageAvailability($record)['description']),
|
||||||
TextColumn::make('latest_review')
|
TextColumn::make('latest_review')
|
||||||
->label(__('localization.review.latest_review'))
|
->label(__('localization.review.status'))
|
||||||
|
->width('9rem')
|
||||||
->badge()
|
->badge()
|
||||||
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
|
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
|
||||||
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
|
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record)),
|
||||||
->icon(fn (Tenant $record): ?string => $this->latestReviewStateIcon($record))
|
TextColumn::make('evidence_proof_state')
|
||||||
->iconColor(fn (Tenant $record): ?string => $this->latestReviewStateIconColor($record))
|
->label(__('localization.review.evidence_status'))
|
||||||
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
|
->width('8rem')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (Tenant $record): string => $this->evidenceStatusLabel($record))
|
||||||
|
->color(fn (Tenant $record): string => $this->evidenceStatusColor($record)),
|
||||||
|
TextColumn::make('recommended_next_action')
|
||||||
|
->label(__('localization.review.next_step'))
|
||||||
|
->width('10rem')
|
||||||
|
->extraHeaderAttributes(['class' => 'whitespace-normal'])
|
||||||
|
->getStateUsing(fn (Tenant $record): string => $this->controlRecommendedNextAction($record))
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('finding_summary')
|
TextColumn::make('open_review')
|
||||||
->label(__('localization.review.key_findings'))
|
->label(__('localization.review.open'))
|
||||||
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
|
->width('8rem')
|
||||||
->wrap(),
|
->getStateUsing(fn (): string => __('localization.review.open_review'))
|
||||||
TextColumn::make('accepted_risk_summary')
|
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
||||||
->label(__('localization.review.accepted_risks'))
|
->color('primary'),
|
||||||
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('published_at')
|
|
||||||
->label(__('localization.review.published'))
|
|
||||||
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('—'),
|
|
||||||
TextColumn::make('review_pack_state')
|
|
||||||
->label(__('localization.review.review_pack'))
|
|
||||||
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
|
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('tenant_id')
|
SelectFilter::make('tenant_id')
|
||||||
@ -189,24 +203,12 @@ public function table(Table $table): Table
|
|||||||
})
|
})
|
||||||
->searchable(),
|
->searchable(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([])
|
||||||
Action::make('open_latest_review')
|
|
||||||
->label(__('localization.review.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(__('localization.review.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([])
|
->bulkActions([])
|
||||||
->emptyStateHeading(__('localization.review.no_entitled_tenants'))
|
->emptyStateHeading(__('localization.review.no_released_customer_reviews'))
|
||||||
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
|
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
|
||||||
? __('localization.review.clear_filters_description')
|
? __('localization.review.clear_filters_description')
|
||||||
: __('localization.review.adjust_filters_description'))
|
: __('localization.review.no_released_customer_reviews_description'))
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
Action::make('clear_filters_empty')
|
Action::make('clear_filters_empty')
|
||||||
->label(__('localization.review.clear_filters'))
|
->label(__('localization.review.clear_filters'))
|
||||||
@ -260,6 +262,34 @@ private function authorizePageAccess(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function auditWorkspaceOpen(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(WorkspaceAuditLogger::class)->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::CustomerReviewWorkspaceOpened,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'source_surface' => self::SOURCE_SURFACE,
|
||||||
|
'tenant_filter_id' => $this->currentTenantFilterId(),
|
||||||
|
'entitled_tenant_count' => count($this->authorizedTenants()),
|
||||||
|
'interpretation_version' => $this->currentTenantFilterInterpretationVersion(),
|
||||||
|
'interpretation_versions' => $this->visibleInterpretationVersions(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actor: $user,
|
||||||
|
resourceType: 'customer_review_workspace',
|
||||||
|
resourceId: (string) $workspace->getKey(),
|
||||||
|
targetLabel: __('localization.review.customer_review_workspace'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function workspaceQuery(): Builder
|
private function workspaceQuery(): Builder
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -361,47 +391,19 @@ private function latestReviewUrl(Tenant $tenant): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->appendQuery(
|
$query = array_filter(
|
||||||
TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'),
|
|
||||||
array_replace(
|
array_replace(
|
||||||
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
|
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
|
||||||
|
[
|
||||||
|
'source_surface' => self::SOURCE_SURFACE,
|
||||||
|
'tenant_filter_id' => $this->currentTenantFilterId(),
|
||||||
|
],
|
||||||
$this->navigationContext()?->toQuery() ?? [],
|
$this->navigationContext()?->toQuery() ?? [],
|
||||||
),
|
),
|
||||||
|
static fn (mixed $value): bool => $value !== null && $value !== '',
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
private function latestReviewPack(Tenant $tenant): ?ReviewPack
|
return $this->appendQuery(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'), $query);
|
||||||
{
|
|
||||||
$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
|
private function latestPublishedAt(Tenant $tenant): ?\Illuminate\Support\Carbon
|
||||||
@ -434,12 +436,34 @@ private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
|
|||||||
|
|
||||||
private function latestReviewStateLabel(Tenant $tenant): string
|
private function latestReviewStateLabel(Tenant $tenant): string
|
||||||
{
|
{
|
||||||
return $this->reviewOutcome($tenant)?->primaryLabel ?? __('localization.review.no_published_review');
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
|
if (! $review instanceof TenantReview) {
|
||||||
|
return __('localization.review.no_published_review');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->workspaceReviewNeedsAttention($tenant)
|
||||||
|
? __('localization.review.review_requires_attention')
|
||||||
|
: __('localization.review.ready_for_release');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function latestReviewStateColor(Tenant $tenant): string
|
private function latestReviewStateColor(Tenant $tenant): string
|
||||||
{
|
{
|
||||||
return $this->reviewOutcome($tenant)?->primaryBadge->color ?? 'gray';
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
|
if (! $review instanceof TenantReview) {
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
$packageState = $this->governancePackageAvailability($tenant)['state'];
|
||||||
|
|
||||||
|
if (! $this->workspaceReviewNeedsAttention($tenant)) {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($packageState, ['blocked', 'expired'], true)
|
||||||
|
? 'danger'
|
||||||
|
: 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function latestReviewStateIcon(Tenant $tenant): ?string
|
private function latestReviewStateIcon(Tenant $tenant): ?string
|
||||||
@ -477,6 +501,342 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string
|
|||||||
return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
|
return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function controlReadinessLabel(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
$control = $this->primaryControlSummary($tenant);
|
||||||
|
|
||||||
|
if ($control === null) {
|
||||||
|
return __('localization.review.control_readiness_unmapped');
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $control['readiness_label'] ?? null;
|
||||||
|
|
||||||
|
return is_string($label) && trim($label) !== ''
|
||||||
|
? $label
|
||||||
|
: ComplianceEvidenceMappingV1::readinessLabel((string) ($control['readiness_bucket'] ?? 'review_recommended'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function governancePackageSummary(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
|
if (! $review instanceof TenantReview) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = is_array($review->summary) ? $review->summary : [];
|
||||||
|
$package = is_array($summary['governance_package'] ?? null) ? $summary['governance_package'] : [];
|
||||||
|
|
||||||
|
return $package;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{state:string,label:string,description:string}
|
||||||
|
*/
|
||||||
|
private function governancePackageAvailability(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
|
if (! $review instanceof TenantReview) {
|
||||||
|
return [
|
||||||
|
'state' => 'unavailable',
|
||||||
|
'label' => __('localization.review.governance_package_unavailable'),
|
||||||
|
'description' => __('localization.review.no_published_review_available'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$pack = $review->currentExportReviewPack;
|
||||||
|
$user = auth()->user();
|
||||||
|
$limitations = is_array($review->controlInterpretation()['limitations'] ?? null) ? $review->controlInterpretation()['limitations'] : [];
|
||||||
|
$isPartialReview = in_array((string) $review->completeness_state, [
|
||||||
|
TenantReviewCompletenessState::Partial->value,
|
||||||
|
TenantReviewCompletenessState::Stale->value,
|
||||||
|
], true) || $limitations !== [];
|
||||||
|
|
||||||
|
if (! $pack instanceof ReviewPack) {
|
||||||
|
return [
|
||||||
|
'state' => 'unavailable',
|
||||||
|
'label' => __('localization.review.governance_package_unavailable'),
|
||||||
|
'description' => __('localization.review.governance_package_unavailable_description'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||||
|
return [
|
||||||
|
'state' => 'blocked',
|
||||||
|
'label' => __('localization.review.governance_package_blocked'),
|
||||||
|
'description' => __('localization.review.governance_package_blocked_description'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pack->status === ReviewPackStatus::Expired->value || ($pack->expires_at !== null && $pack->expires_at->isPast())) {
|
||||||
|
return [
|
||||||
|
'state' => 'expired',
|
||||||
|
'label' => __('localization.review.governance_package_expired'),
|
||||||
|
'description' => __('localization.review.governance_package_expired_description'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||||
|
return [
|
||||||
|
'state' => 'unavailable',
|
||||||
|
'label' => __('localization.review.governance_package_unavailable'),
|
||||||
|
'description' => __('localization.review.governance_package_not_ready_description'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isPartialReview) {
|
||||||
|
return [
|
||||||
|
'state' => 'partial',
|
||||||
|
'label' => __('localization.review.governance_package_partial'),
|
||||||
|
'description' => __('localization.review.governance_package_partial_description'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'state' => 'available',
|
||||||
|
'label' => __('localization.review.governance_package_available'),
|
||||||
|
'description' => __('localization.review.governance_package_available_description'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function governancePackageAvailabilityLabel(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
return match ($this->governancePackageAvailability($tenant)['state']) {
|
||||||
|
'available' => __('localization.review.available'),
|
||||||
|
'partial' => __('localization.review.partial'),
|
||||||
|
'blocked' => __('localization.review.blocked'),
|
||||||
|
'expired' => __('localization.review.expired'),
|
||||||
|
default => __('localization.review.unavailable'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function governancePackageAvailabilityColor(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
return match ($this->governancePackageAvailability($tenant)['state']) {
|
||||||
|
'available' => 'success',
|
||||||
|
'partial' => 'warning',
|
||||||
|
'blocked', 'expired' => 'danger',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function governancePackageTeaser(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
$package = $this->governancePackageSummary($tenant);
|
||||||
|
|
||||||
|
$executiveSummary = $package['executive_summary'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($executiveSummary) && trim($executiveSummary) !== '') {
|
||||||
|
return $executiveSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->governancePackageAvailability($tenant)['description'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function controlReadinessColor(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
return match ((string) ($this->primaryControlSummary($tenant)['readiness_bucket'] ?? 'unmapped')) {
|
||||||
|
'follow_up_required' => 'warning',
|
||||||
|
'review_recommended' => 'info',
|
||||||
|
'evidence_on_record' => 'success',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function controlReadinessDescription(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
|
if (! $review instanceof TenantReview) {
|
||||||
|
return __('localization.review.no_published_review_available');
|
||||||
|
}
|
||||||
|
|
||||||
|
$controls = $review->controlInterpretationControls();
|
||||||
|
|
||||||
|
if ($controls === []) {
|
||||||
|
return __('localization.review.control_readiness_unmapped_description');
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = collect($controls)
|
||||||
|
->take(2)
|
||||||
|
->map(function (array $control): string {
|
||||||
|
$name = is_string($control['control_name'] ?? null) ? $control['control_name'] : __('localization.review.control');
|
||||||
|
$label = is_string($control['readiness_label'] ?? null)
|
||||||
|
? $control['readiness_label']
|
||||||
|
: ComplianceEvidenceMappingV1::readinessLabel((string) ($control['readiness_bucket'] ?? 'review_recommended'));
|
||||||
|
|
||||||
|
return $name.': '.$label;
|
||||||
|
})
|
||||||
|
->implode(' · ');
|
||||||
|
|
||||||
|
$remaining = count($controls) - 2;
|
||||||
|
|
||||||
|
if ($remaining > 0) {
|
||||||
|
$summary .= ' · '.__('localization.review.additional_controls', ['count' => $remaining]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$limitations = $this->controlLimitationSummary($review);
|
||||||
|
|
||||||
|
return trim($summary.($limitations !== null ? ' '.$limitations : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function controlEvidenceBasisSummary(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
$control = $this->primaryControlSummary($tenant);
|
||||||
|
|
||||||
|
if ($control === null) {
|
||||||
|
return __('localization.review.control_evidence_unmapped');
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = $control['evidence_basis_summary'] ?? null;
|
||||||
|
|
||||||
|
return is_string($summary) && trim($summary) !== ''
|
||||||
|
? $summary
|
||||||
|
: __('localization.review.control_evidence_unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function controlRecommendedNextAction(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
if ($this->primaryControlSummary($tenant) === null) {
|
||||||
|
return __('localization.review.workspace_next_step_control_mapping');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->evidenceStatusState($tenant) !== 'available') {
|
||||||
|
return __('localization.review.workspace_next_step_evidence_review');
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this->governancePackageAvailability($tenant)['state']) {
|
||||||
|
'available', 'partial' => __('localization.review.workspace_next_step_package_review'),
|
||||||
|
default => __('localization.review.workspace_next_step_review_open'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function workspaceReviewNeedsAttention(Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
|
if (! $review instanceof TenantReview) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->primaryControlSummary($tenant) === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->evidenceStatusState($tenant) !== 'available') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->governancePackageAvailability($tenant)['state'] !== 'available';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evidenceStatusState(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
|
if (! $review instanceof TenantReview) {
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshot = $review->evidenceSnapshot;
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||||
|
return 'restricted';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
||||||
|
return 'expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'available';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evidenceStatusLabelForState(string $state): string
|
||||||
|
{
|
||||||
|
return match ($state) {
|
||||||
|
'available' => __('localization.review.available'),
|
||||||
|
'restricted' => __('localization.review.restricted'),
|
||||||
|
'expired' => __('localization.review.expired'),
|
||||||
|
default => __('localization.review.pending'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evidenceStatusColorForState(string $state): string
|
||||||
|
{
|
||||||
|
return match ($state) {
|
||||||
|
'available' => 'success',
|
||||||
|
'restricted', 'expired' => 'danger',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function controlRecommendedNextActionDescription(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
$control = $this->primaryControlSummary($tenant);
|
||||||
|
|
||||||
|
if ($control === null) {
|
||||||
|
return __('localization.review.control_recommendation_unmapped');
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $control['recommended_next_action'] ?? null;
|
||||||
|
|
||||||
|
return is_string($action) && trim($action) !== ''
|
||||||
|
? $action
|
||||||
|
: __('localization.review.no_action_needed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function primaryControlSummary(Tenant $tenant): ?array
|
||||||
|
{
|
||||||
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
|
if (! $review instanceof TenantReview) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$controls = collect($review->controlInterpretationControls());
|
||||||
|
|
||||||
|
return $controls
|
||||||
|
->sortBy(static fn (array $control): int => match ((string) ($control['readiness_bucket'] ?? '')) {
|
||||||
|
'follow_up_required' => 0,
|
||||||
|
'review_recommended' => 1,
|
||||||
|
'evidence_on_record' => 2,
|
||||||
|
default => 3,
|
||||||
|
})
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function controlLimitationSummary(TenantReview $review): ?string
|
||||||
|
{
|
||||||
|
$counts = $review->controlInterpretationLimitationCounts();
|
||||||
|
|
||||||
|
if ($counts === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = collect($counts)
|
||||||
|
->filter(static fn (int $count): bool => $count > 0)
|
||||||
|
->keys()
|
||||||
|
->map(static fn (string $flag): string => ComplianceEvidenceMappingV1::limitationLabel($flag))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $labels === []
|
||||||
|
? null
|
||||||
|
: __('localization.review.control_limitations_summary', ['limitations' => implode(', ', $labels)]);
|
||||||
|
}
|
||||||
|
|
||||||
private function findingSummary(Tenant $tenant): string
|
private function findingSummary(Tenant $tenant): string
|
||||||
{
|
{
|
||||||
$review = $this->latestPublishedReview($tenant);
|
$review = $this->latestPublishedReview($tenant);
|
||||||
@ -518,31 +878,142 @@ private function acceptedRiskSummary(Tenant $tenant): string
|
|||||||
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
|
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
|
||||||
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
|
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
|
||||||
|
|
||||||
return match (true) {
|
$countSummary = match (true) {
|
||||||
$statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'),
|
$statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'),
|
||||||
$warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
|
$warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
|
||||||
$validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
|
$validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
|
||||||
default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]),
|
default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$accountability = $this->acceptedRiskAccountability($tenant);
|
||||||
|
|
||||||
|
return $accountability === null
|
||||||
|
? $countSummary
|
||||||
|
: $countSummary.' '.$accountability;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reviewPackAvailability(Tenant $tenant): string
|
private function evidenceProofAvailability(Tenant $tenant): string
|
||||||
{
|
{
|
||||||
$pack = $this->latestReviewPack($tenant);
|
$review = $this->latestPublishedReview($tenant);
|
||||||
|
|
||||||
if (! $pack instanceof ReviewPack) {
|
if (! $review instanceof TenantReview) {
|
||||||
return __('localization.review.unavailable');
|
return __('localization.review.no_published_review_available');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
$snapshot = $review->evidenceSnapshot;
|
||||||
return __('localization.review.unavailable');
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||||
|
return __('localization.review.evidence_proof_absent');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||||
return __('localization.review.unavailable');
|
return __('localization.review.evidence_proof_access_unavailable');
|
||||||
}
|
}
|
||||||
|
|
||||||
return __('localization.review.available');
|
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
||||||
|
return __('localization.review.evidence_proof_expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
return __('localization.review.evidence_proof_available');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evidenceStatusLabel(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
return $this->evidenceStatusLabelForState($this->evidenceStatusState($tenant));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evidenceStatusColor(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
return $this->evidenceStatusColorForState($this->evidenceStatusState($tenant));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function visibleInterpretationVersions(): array
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(TenantReviewRegisterService::class)
|
||||||
|
->latestPublishedQuery($user, $workspace)
|
||||||
|
->get()
|
||||||
|
->map(static fn (TenantReview $review): ?string => $review->controlInterpretationVersion())
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentTenantFilterInterpretationVersion(): ?string
|
||||||
|
{
|
||||||
|
$tenantId = $this->currentTenantFilterId();
|
||||||
|
|
||||||
|
if ($tenantId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->authorizedTenants()[$tenantId] ?? null;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant->tenantReviews()->published()
|
||||||
|
->latest('published_at')
|
||||||
|
->latest('generated_at')
|
||||||
|
->latest('id')
|
||||||
|
->first()
|
||||||
|
?->controlInterpretationVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function acceptedRiskAccountability(Tenant $tenant): ?string
|
||||||
|
{
|
||||||
|
$exception = FindingException::query()
|
||||||
|
->with(['owner', 'approver', 'currentDecision'])
|
||||||
|
->where('workspace_id', (int) $tenant->workspace_id)
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->current()
|
||||||
|
->orderByRaw("case when current_validity_state in ('valid', 'expiring') then 0 else 1 end")
|
||||||
|
->latest('approved_at')
|
||||||
|
->latest('requested_at')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $exception instanceof FindingException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$accountable = $exception->owner?->name
|
||||||
|
?? $exception->approver?->name;
|
||||||
|
$decisionType = $exception->currentDecision?->decision_type;
|
||||||
|
$reviewDue = $exception->review_due_at ?? $exception->expires_at;
|
||||||
|
$reason = is_string($exception->request_reason) ? trim($exception->request_reason) : '';
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
if (is_string($accountable) && trim($accountable) !== '') {
|
||||||
|
$parts[] = $reviewDue === null
|
||||||
|
? __('localization.review.accepted_risk_accountable', ['name' => $accountable])
|
||||||
|
: __('localization.review.accepted_risk_accountable_until', [
|
||||||
|
'name' => $accountable,
|
||||||
|
'date' => $reviewDue->toDateString(),
|
||||||
|
]);
|
||||||
|
} elseif (is_string($decisionType) && trim($decisionType) !== '') {
|
||||||
|
$parts[] = __('localization.review.accepted_risk_partial_accountability');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reason !== '') {
|
||||||
|
$parts[] = __('localization.review.accepted_risk_reason', [
|
||||||
|
'reason' => Str::limit($reason, 160),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts === [] ? null : implode(' ', $parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function navigationContext(): ?CanonicalNavigationContext
|
private function navigationContext(): ?CanonicalNavigationContext
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Support\Ai\AiPolicyMode;
|
use App\Support\Ai\AiPolicyMode;
|
||||||
use App\Support\Ai\AiUseCaseCatalog;
|
use App\Support\Ai\AiUseCaseCatalog;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
||||||
use App\Services\Localization\LocaleResolver;
|
use App\Services\Localization\LocaleResolver;
|
||||||
@ -141,6 +142,11 @@ class WorkspaceSettings extends Page
|
|||||||
*/
|
*/
|
||||||
public array $entitlementSummary = [];
|
public array $entitlementSummary = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
public array $commercialLifecycleSummary = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
|
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
|
||||||
*
|
*
|
||||||
@ -227,6 +233,24 @@ public function content(Schema $schema): Schema
|
|||||||
->helperText(fn (): string => $this->localeDefaultHelperText())
|
->helperText(fn (): string => $this->localeDefaultHelperText())
|
||||||
->hintAction($this->makeResetAction('localization_default_locale')),
|
->hintAction($this->makeResetAction('localization_default_locale')),
|
||||||
]),
|
]),
|
||||||
|
Section::make('Commercial posture')
|
||||||
|
->description('Read-only subscription-backed or fallback-backed commercial posture for this workspace.')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Placeholder::make('commercial_posture_source')
|
||||||
|
->label('Commercial source')
|
||||||
|
->content(fn (): string => $this->commercialPostureSourceText()),
|
||||||
|
Placeholder::make('commercial_posture_state')
|
||||||
|
->label('Commercial state')
|
||||||
|
->content(fn (): string => $this->commercialPostureStateText()),
|
||||||
|
Placeholder::make('commercial_posture_timing')
|
||||||
|
->label('Commercial timing')
|
||||||
|
->content(fn (): string => $this->commercialPostureTimingText()),
|
||||||
|
Placeholder::make('commercial_posture_reason')
|
||||||
|
->label('Explanation')
|
||||||
|
->content(fn (): string => $this->commercialPostureReasonText())
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
Section::make('Workspace entitlements')
|
Section::make('Workspace entitlements')
|
||||||
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
|
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
|
||||||
->columns(2)
|
->columns(2)
|
||||||
@ -653,6 +677,7 @@ private function loadFormState(): void
|
|||||||
$this->workspaceOverrides = $workspaceOverrides;
|
$this->workspaceOverrides = $workspaceOverrides;
|
||||||
$this->resolvedSettings = $resolvedSettings;
|
$this->resolvedSettings = $resolvedSettings;
|
||||||
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
|
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
|
||||||
|
$this->commercialLifecycleSummary = app(WorkspaceCommercialLifecycleResolver::class)->summary($this->workspace);
|
||||||
|
|
||||||
$this->loadDomainLastModified();
|
$this->loadDomainLastModified();
|
||||||
}
|
}
|
||||||
@ -945,6 +970,43 @@ private function entitlementSourceLabel(array $decision): string
|
|||||||
return 'plan profile default';
|
return 'plan profile default';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function commercialPostureSourceText(): string
|
||||||
|
{
|
||||||
|
return ($this->commercialLifecycleSummary['fallback_status'] ?? true) ? 'fallback-backed' : 'subscription-backed';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function commercialPostureStateText(): string
|
||||||
|
{
|
||||||
|
return (string) ($this->commercialLifecycleSummary['subscription_state_label']
|
||||||
|
?? $this->commercialLifecycleSummary['state_label']
|
||||||
|
?? 'Active paid');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function commercialPostureTimingText(): string
|
||||||
|
{
|
||||||
|
$label = $this->commercialLifecycleSummary['subscription_key_date_label'] ?? null;
|
||||||
|
$date = $this->commercialLifecycleSummary['subscription_key_date'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($label) && $label !== '' && $date instanceof Carbon) {
|
||||||
|
return sprintf('%s: %s', $label, $date->toDayDateTimeString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'No scheduled commercial date recorded.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function commercialPostureReasonText(): string
|
||||||
|
{
|
||||||
|
$reason = $this->commercialLifecycleSummary['rationale'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($reason) && $reason !== '') {
|
||||||
|
return $reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($this->commercialLifecycleSummary['fallback_status'] ?? true)
|
||||||
|
? 'No current subscription record is stored. The workspace is using fallback lifecycle truth.'
|
||||||
|
: 'No explicit commercial explanation recorded.';
|
||||||
|
}
|
||||||
|
|
||||||
private function helperTextFor(string $field): string
|
private function helperTextFor(string $field): string
|
||||||
{
|
{
|
||||||
$resolved = $this->resolvedSettings[$field] ?? null;
|
$resolved = $this->resolvedSettings[$field] ?? null;
|
||||||
|
|||||||
@ -4,13 +4,10 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||||
use App\Filament\Widgets\Tenant\TenantTriageArrivalContinuity;
|
use App\Filament\Widgets\Tenant\TenantTriageArrivalContinuity;
|
||||||
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
|
use App\Filament\Widgets\Dashboard\TenantDashboardContextChips;
|
||||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
use App\Filament\Widgets\Dashboard\TenantDashboardOverview;
|
||||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
|
||||||
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
|
||||||
use App\Filament\Widgets\Dashboard\RecentOperations;
|
|
||||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
|
||||||
use App\Models\SupportRequest;
|
use App\Models\SupportRequest;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -23,7 +20,10 @@
|
|||||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||||
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
|
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
|
||||||
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
||||||
|
use App\Support\TenantDashboard\TenantDashboardSummary;
|
||||||
|
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\Placeholder;
|
use Filament\Forms\Components\Placeholder;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@ -32,23 +32,59 @@
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Dashboard;
|
use Filament\Pages\Dashboard;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Filament\Support\Enums\Width;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
use Filament\Widgets\WidgetConfiguration;
|
use Filament\Widgets\WidgetConfiguration;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
|
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||||
|
|
||||||
class TenantDashboard extends Dashboard
|
class TenantDashboard extends Dashboard
|
||||||
{
|
{
|
||||||
|
protected Width|string|null $maxContentWidth = Width::Full;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var list<string>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
public array $supportDiagnosticsAuditKeys = [];
|
public array $supportDiagnosticsAuditKeys = [];
|
||||||
|
|
||||||
public function getTitle(): string
|
private ?TenantDashboardSummary $dashboardSummary = null;
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
{
|
{
|
||||||
return __('localization.dashboard.tenant_title');
|
return __('localization.dashboard.tenant_title');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string | Htmlable
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return __('localization.dashboard.tenant_title');
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = $this->dashboardSummary();
|
||||||
|
|
||||||
|
if (! $summary instanceof TenantDashboardSummary) {
|
||||||
|
return (string) $tenant->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HtmlString(sprintf(
|
||||||
|
'<span class="inline-flex flex-wrap items-center gap-3" data-testid="tenant-dashboard-heading"><span>%s</span><span data-testid="tenant-dashboard-posture-pill" class="%s">%s</span></span>',
|
||||||
|
e((string) $tenant->name),
|
||||||
|
e($this->posturePillClasses((string) ($summary->posture['tone'] ?? 'gray'))),
|
||||||
|
e((string) ($summary->posture['status'] ?? '')),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): string | Htmlable | null
|
||||||
|
{
|
||||||
|
return __('localization.dashboard.overview.page_subheading');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<mixed> $parameters
|
* @param array<mixed> $parameters
|
||||||
*/
|
*/
|
||||||
@ -63,19 +99,15 @@ public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?
|
|||||||
public function getWidgets(): array
|
public function getWidgets(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
TenantTriageArrivalContinuity::class,
|
TenantDashboardContextChips::class,
|
||||||
RecoveryReadiness::class,
|
|
||||||
DashboardKpis::class,
|
DashboardKpis::class,
|
||||||
NeedsAttention::class,
|
TenantDashboardOverview::class,
|
||||||
BaselineCompareNow::class,
|
|
||||||
RecentDriftFindings::class,
|
|
||||||
RecentOperations::class,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getColumns(): int|array
|
public function getColumns(): int|array
|
||||||
{
|
{
|
||||||
return 2;
|
return ['default' => 1, 'xl' => 12];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -83,10 +115,193 @@ public function getColumns(): int|array
|
|||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
$actions = [];
|
||||||
|
|
||||||
|
if ($primaryAction = $this->primaryFollowUpHeaderAction()) {
|
||||||
|
$actions[] = $primaryAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
$moreActions = array_values(array_filter([
|
||||||
|
$this->secondaryHeaderAction(),
|
||||||
$this->requestSupportAction(),
|
$this->requestSupportAction(),
|
||||||
$this->openSupportDiagnosticsAction(),
|
$this->openSupportDiagnosticsAction(),
|
||||||
];
|
]));
|
||||||
|
|
||||||
|
if ($moreActions !== []) {
|
||||||
|
$actions[] = ActionGroup::make($moreActions)
|
||||||
|
->label(__('localization.dashboard.more_actions'))
|
||||||
|
->icon('heroicon-o-ellipsis-horizontal')
|
||||||
|
->color('gray');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function primaryFollowUpHeaderAction(): ?Action
|
||||||
|
{
|
||||||
|
$payload = $this->primaryFollowUpHeaderPayload();
|
||||||
|
|
||||||
|
if (! is_array($payload)) {
|
||||||
|
return $this->governanceInboxHeaderAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->summaryHeaderAction(
|
||||||
|
name: 'primaryFollowUp',
|
||||||
|
payload: $payload,
|
||||||
|
color: 'primary',
|
||||||
|
icon: 'heroicon-o-bolt',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function secondaryHeaderAction(): ?Action
|
||||||
|
{
|
||||||
|
$payload = $this->secondaryHeaderPayload();
|
||||||
|
|
||||||
|
if (! is_array($payload)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->summaryHeaderAction(
|
||||||
|
name: 'reviewOutput',
|
||||||
|
payload: $payload,
|
||||||
|
color: 'gray',
|
||||||
|
icon: 'heroicon-o-document-duplicate',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function primaryFollowUpHeaderPayload(): ?array
|
||||||
|
{
|
||||||
|
$summary = $this->dashboardSummary();
|
||||||
|
|
||||||
|
if (! $summary instanceof TenantDashboardSummary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $summary->recommendedActions[0] ?? null;
|
||||||
|
|
||||||
|
return is_array($payload) && filled($payload['actionLabel'] ?? null)
|
||||||
|
? $payload
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function secondaryHeaderPayload(): ?array
|
||||||
|
{
|
||||||
|
$summary = $this->dashboardSummary();
|
||||||
|
|
||||||
|
if (! $summary instanceof TenantDashboardSummary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$primaryPayload = $this->primaryFollowUpHeaderPayload();
|
||||||
|
|
||||||
|
foreach ($summary->readinessCards as $payload) {
|
||||||
|
if (! is_array($payload) || ! filled($payload['actionLabel'] ?? null)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($payload['key'] ?? null, ['customer_safe_output', 'current_review'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
is_array($primaryPayload)
|
||||||
|
&& ($payload['actionLabel'] ?? null) === ($primaryPayload['actionLabel'] ?? null)
|
||||||
|
&& ($payload['actionUrl'] ?? null) === ($primaryPayload['actionUrl'] ?? null)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function governanceInboxHeaderAction(): ?Action
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User || ! $user->canAccessTenant($tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Action::make('primaryFollowUp')
|
||||||
|
->label(__('localization.dashboard.overview.action_open_governance_inbox'))
|
||||||
|
->icon('heroicon-o-inbox-stack')
|
||||||
|
->color('primary')
|
||||||
|
->url(GovernanceInbox::getUrl(panel: 'admin').'?'.http_build_query([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private function summaryHeaderAction(string $name, array $payload, string $color, string $icon): ?Action
|
||||||
|
{
|
||||||
|
$label = $payload['actionLabel'] ?? null;
|
||||||
|
$url = $payload['actionUrl'] ?? null;
|
||||||
|
$helperText = $payload['helperText'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($label) || $label === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((! is_string($url) || $url === '') && (! is_string($helperText) || $helperText === '')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = Action::make($name)
|
||||||
|
->label($label)
|
||||||
|
->icon($icon)
|
||||||
|
->color($color);
|
||||||
|
|
||||||
|
if (is_string($url) && $url !== '') {
|
||||||
|
$action->url($url);
|
||||||
|
} else {
|
||||||
|
$action->disabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($helperText) && $helperText !== '') {
|
||||||
|
$action->tooltip($helperText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $action;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dashboardSummary(): ?TenantDashboardSummary
|
||||||
|
{
|
||||||
|
if ($this->dashboardSummary instanceof TenantDashboardSummary) {
|
||||||
|
return $this->dashboardSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->dashboardSummary = app(TenantDashboardSummaryBuilder::class)->build($tenant, $user);
|
||||||
|
|
||||||
|
return $this->dashboardSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function posturePillClasses(string $tone): string
|
||||||
|
{
|
||||||
|
return match ($tone) {
|
||||||
|
'success' => 'inline-flex items-center rounded-full border border-success-200 bg-success-50 px-3 py-1 text-sm font-medium text-success-700 shadow-sm dark:border-success-800 dark:bg-success-500/10 dark:text-success-300',
|
||||||
|
'danger' => 'inline-flex items-center rounded-full border border-danger-200 bg-danger-50 px-3 py-1 text-sm font-medium text-danger-700 shadow-sm dark:border-danger-800 dark:bg-danger-500/10 dark:text-danger-300',
|
||||||
|
'warning' => 'inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-3 py-1 text-sm font-medium text-warning-700 shadow-sm dark:border-warning-800 dark:bg-warning-500/10 dark:text-warning-300',
|
||||||
|
default => 'inline-flex items-center rounded-full border border-gray-200 bg-white px-3 py-1 text-sm font-medium text-gray-700 shadow-sm dark:border-white/10 dark:bg-white/5 dark:text-gray-300',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public function authorizeTenantSupportRequest(): void
|
public function authorizeTenantSupportRequest(): void
|
||||||
|
|||||||
@ -4570,15 +4570,17 @@ private function completionSummaryEntitlementSummary(): string
|
|||||||
$currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0);
|
$currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0);
|
||||||
$effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0);
|
$effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0);
|
||||||
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
|
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
|
||||||
|
$commercialSourceLabel = $this->completionSummaryCommercialSourceLabel($decision);
|
||||||
$stateLabel = is_string($decision['state_label'] ?? null) ? $decision['state_label'] : 'Active paid';
|
$stateLabel = is_string($decision['state_label'] ?? null) ? $decision['state_label'] : 'Active paid';
|
||||||
|
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'%s - %s - %d active of %d allowed (%s)',
|
'%s - %s - %d active of %d allowed (%s, %s)',
|
||||||
$this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed',
|
$this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed',
|
||||||
$stateLabel,
|
$stateLabel,
|
||||||
$currentUsage,
|
$currentUsage,
|
||||||
$effectiveValue,
|
$effectiveValue,
|
||||||
$sourceLabel,
|
$sourceLabel,
|
||||||
|
$commercialSourceLabel,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4640,6 +4642,16 @@ private function completionSummaryEntitlementSourceLabel(array $decision): strin
|
|||||||
: 'plan profile default';
|
: 'plan profile default';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $decision
|
||||||
|
*/
|
||||||
|
private function completionSummaryCommercialSourceLabel(array $decision): string
|
||||||
|
{
|
||||||
|
return ($decision['source'] ?? null) === WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SUBSCRIPTION
|
||||||
|
? 'subscription-backed'
|
||||||
|
: 'fallback-backed';
|
||||||
|
}
|
||||||
|
|
||||||
private function completionActionTooltip(): ?string
|
private function completionActionTooltip(): ?string
|
||||||
{
|
{
|
||||||
if (! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)) {
|
if (! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)) {
|
||||||
|
|||||||
@ -174,9 +174,12 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->label('Operation')
|
->label('Operation')
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||||
->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null)
|
->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null)
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab()
|
||||||
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||||
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono')
|
||||||
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||||
|
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono')
|
||||||
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
Section::make('Summary')
|
Section::make('Summary')
|
||||||
@ -222,6 +225,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->label('Raw summary JSON')
|
->label('Raw summary JSON')
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
|
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
|
||||||
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->columns(4),
|
->columns(4),
|
||||||
@ -236,7 +240,7 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
|||||||
{
|
{
|
||||||
$entries = [];
|
$entries = [];
|
||||||
|
|
||||||
if (is_numeric($record->operation_run_id)) {
|
if (! static::isCustomerWorkspaceFlow() && is_numeric($record->operation_run_id)) {
|
||||||
$entries[] = RelatedContextEntry::available(
|
$entries[] = RelatedContextEntry::available(
|
||||||
key: 'operation_run',
|
key: 'operation_run',
|
||||||
label: 'Operation',
|
label: 'Operation',
|
||||||
@ -255,12 +259,18 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($pack instanceof \App\Models\ReviewPack && $pack->tenant instanceof Tenant) {
|
if ($pack instanceof \App\Models\ReviewPack && $pack->tenant instanceof Tenant) {
|
||||||
|
$packUrl = ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
|
||||||
|
|
||||||
|
if (static::isCustomerWorkspaceFlow()) {
|
||||||
|
$packUrl = static::appendQuery($packUrl, static::customerWorkspaceContextQuery());
|
||||||
|
}
|
||||||
|
|
||||||
$entries[] = RelatedContextEntry::available(
|
$entries[] = RelatedContextEntry::available(
|
||||||
key: 'review_pack',
|
key: 'review_pack',
|
||||||
label: 'Review pack',
|
label: 'Review pack',
|
||||||
value: sprintf('#%d', (int) $pack->getKey()),
|
value: sprintf('#%d', (int) $pack->getKey()),
|
||||||
secondaryValue: 'Inspect the latest executive-pack output for this evidence basis.',
|
secondaryValue: 'Inspect the latest executive-pack output for this evidence basis.',
|
||||||
targetUrl: ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant),
|
targetUrl: $packUrl,
|
||||||
targetKind: 'direct_record',
|
targetKind: 'direct_record',
|
||||||
priority: 20,
|
priority: 20,
|
||||||
actionLabel: 'View review pack',
|
actionLabel: 'View review pack',
|
||||||
@ -285,6 +295,36 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
|||||||
return $entries;
|
return $entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function isCustomerWorkspaceFlow(): bool
|
||||||
|
{
|
||||||
|
return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function customerWorkspaceContextQuery(): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||||
|
'review_id' => request()->query('review_id'),
|
||||||
|
'interpretation_version' => request()->query('interpretation_version'),
|
||||||
|
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $query
|
||||||
|
*/
|
||||||
|
private static function appendQuery(string $url, array $query): string
|
||||||
|
{
|
||||||
|
if ($query === []) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||||
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
|
|||||||
@ -5,8 +5,13 @@
|
|||||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||||
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
|
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\Ui\GovernanceActions\GovernanceActionCatalog;
|
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||||
@ -20,6 +25,13 @@ class ViewEvidenceSnapshot extends ViewRecord
|
|||||||
{
|
{
|
||||||
protected static string $resource = EvidenceSnapshotResource::class;
|
protected static string $resource = EvidenceSnapshotResource::class;
|
||||||
|
|
||||||
|
public function mount(int|string $record): void
|
||||||
|
{
|
||||||
|
parent::mount($record);
|
||||||
|
|
||||||
|
$this->auditCustomerWorkspaceProofOpen();
|
||||||
|
}
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
protected function resolveRecord(int|string $key): Model
|
||||||
{
|
{
|
||||||
return EvidenceSnapshotResource::resolveScopedRecordOrFail($key);
|
return EvidenceSnapshotResource::resolveScopedRecordOrFail($key);
|
||||||
@ -27,6 +39,10 @@ protected function resolveRecord(int|string $key): Model
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
|
if (EvidenceSnapshotResource::isCustomerWorkspaceFlow()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$refreshRule = GovernanceActionCatalog::rule('refresh_evidence');
|
$refreshRule = GovernanceActionCatalog::rule('refresh_evidence');
|
||||||
$expireRule = GovernanceActionCatalog::rule('expire_snapshot');
|
$expireRule = GovernanceActionCatalog::rule('expire_snapshot');
|
||||||
|
|
||||||
@ -90,4 +106,44 @@ protected function getHeaderActions(): array
|
|||||||
->apply(),
|
->apply(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function auditCustomerWorkspaceProofOpen(): void
|
||||||
|
{
|
||||||
|
if (! EvidenceSnapshotResource::isCustomerWorkspaceFlow()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = $this->record;
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $record instanceof EvidenceSnapshot || ! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(WorkspaceAuditLogger::class)->log(
|
||||||
|
workspace: $tenant->workspace,
|
||||||
|
action: AuditActionId::EvidenceSnapshotOpened,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'evidence_snapshot_id' => (int) $record->getKey(),
|
||||||
|
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||||
|
'review_id' => request()->query('review_id'),
|
||||||
|
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||||
|
'interpretation_version' => request()->query('interpretation_version'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actor: $user,
|
||||||
|
resourceType: 'evidence_snapshot',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
targetLabel: sprintf('Evidence snapshot #%d', (int) $record->getKey()),
|
||||||
|
tenant: $tenant,
|
||||||
|
operationRunId: $record->operation_run_id !== null ? (int) $record->operation_run_id : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Findings\FindingExceptionService;
|
use App\Services\Findings\FindingExceptionService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
@ -36,7 +37,18 @@ protected function getHeaderActions(): array
|
|||||||
$renewRule = GovernanceActionCatalog::rule('renew_exception');
|
$renewRule = GovernanceActionCatalog::rule('renew_exception');
|
||||||
$revokeRule = GovernanceActionCatalog::rule('revoke_exception');
|
$revokeRule = GovernanceActionCatalog::rule('revoke_exception');
|
||||||
|
|
||||||
return [
|
$actions = [];
|
||||||
|
$navigationContext = $this->navigationContext();
|
||||||
|
|
||||||
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||||
|
$actions[] = Action::make('return_to_decision_register')
|
||||||
|
->label($navigationContext->backLinkLabel)
|
||||||
|
->icon('heroicon-o-arrow-left')
|
||||||
|
->color('gray')
|
||||||
|
->url($navigationContext->backLinkUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($actions, [
|
||||||
Action::make('renew_exception')
|
Action::make('renew_exception')
|
||||||
->label($renewRule->canonicalLabel)
|
->label($renewRule->canonicalLabel)
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
@ -159,7 +171,18 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
$this->refreshFormData(['status', 'current_validity_state', 'revocation_reason', 'revoked_at']);
|
$this->refreshFormData(['status', 'current_validity_state', 'revocation_reason', 'revoked_at']);
|
||||||
}),
|
}),
|
||||||
];
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): ?string
|
||||||
|
{
|
||||||
|
$navigationContext = $this->navigationContext();
|
||||||
|
|
||||||
|
if ($navigationContext?->sourceSurface === 'governance.decision_register') {
|
||||||
|
return 'Opened from the workspace decision register. Use the back action to return to the same register scope.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -199,4 +222,9 @@ private function canManageRecord(): bool
|
|||||||
&& $user->canAccessTenant($record->tenant)
|
&& $user->canAccessTenant($record->tenant)
|
||||||
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function navigationContext(): ?CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
return CanonicalNavigationContext::fromRequest(request());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Support\Enums\Width;
|
||||||
use Filament\Support\Enums\Size;
|
use Filament\Support\Enums\Size;
|
||||||
|
|
||||||
class ListInventoryItems extends ListRecords
|
class ListInventoryItems extends ListRecords
|
||||||
@ -36,6 +37,8 @@ class ListInventoryItems extends ListRecords
|
|||||||
|
|
||||||
protected static string $resource = InventoryItemResource::class;
|
protected static string $resource = InventoryItemResource::class;
|
||||||
|
|
||||||
|
protected Width|string|null $maxContentWidth = Width::Full;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
|||||||
@ -72,6 +72,16 @@ class PolicyResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
public static function getModelLabel(): string
|
||||||
|
{
|
||||||
|
return static::text('common.policy');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPluralModelLabel(): string
|
||||||
|
{
|
||||||
|
return static::text('common.policies');
|
||||||
|
}
|
||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
public static function shouldRegisterNavigation(): bool
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
@ -100,7 +110,7 @@ public static function canViewAny(): bool
|
|||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync policies.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
||||||
@ -112,12 +122,12 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
|||||||
{
|
{
|
||||||
return UiEnforcement::forAction(
|
return UiEnforcement::forAction(
|
||||||
Actions\Action::make($name)
|
Actions\Action::make($name)
|
||||||
->label('Sync from Intune')
|
->label(static::text('resource.sync_action_primary'))
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('Sync policies from Intune')
|
->modalHeading(static::text('resource.sync_modal_heading'))
|
||||||
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
|
->modalDescription(static::text('resource.sync_modal_description').' '.static::text('common.source_microsoft_intune'))
|
||||||
->action(function (Pages\ListPolicies $livewire): void {
|
->action(function (Pages\ListPolicies $livewire): void {
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -150,7 +160,7 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
|||||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -165,14 +175,14 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
|||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
->tooltip('You do not have permission to sync policies.')
|
->tooltip(static::text('resource.sync_permission_tooltip'))
|
||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,16 +195,31 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
Section::make('Policy Details')
|
Section::make(static::text('resource.details_section'))
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('display_name')->label('Policy'),
|
TextEntry::make('display_name')->label(static::text('common.policy')),
|
||||||
TextEntry::make('policy_type')->label('Type'),
|
TextEntry::make('policy_type')->label(static::text('common.type')),
|
||||||
TextEntry::make('platform'),
|
TextEntry::make('platform')
|
||||||
TextEntry::make('external_id')->label('External ID'),
|
->label(static::text('common.platform'))
|
||||||
TextEntry::make('last_synced_at')->dateTime()->label('Last synced'),
|
->badge()
|
||||||
TextEntry::make('created_at')->since(),
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||||
|
TextEntry::make('visibility_state')
|
||||||
|
->label(static::text('common.visibility'))
|
||||||
|
->badge()
|
||||||
|
->state(fn (Policy $record): string => $record->visibilityState())
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyProviderPresence))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::PolicyProviderPresence))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicyProviderPresence))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyProviderPresence))
|
||||||
|
->helperText(fn (Policy $record): ?string => $record->isProviderMissing()
|
||||||
|
? static::text('resource.visibility_source_unavailable_backup_items')
|
||||||
|
: null),
|
||||||
|
TextEntry::make('external_id')->label(static::text('common.external_id')),
|
||||||
|
TextEntry::make('last_synced_at')->dateTime()->label(static::text('common.last_synced')),
|
||||||
|
TextEntry::make('created_at')->since()->label(static::text('common.created')),
|
||||||
TextEntry::make('latest_snapshot_mode')
|
TextEntry::make('latest_snapshot_mode')
|
||||||
->label('Snapshot')
|
->label(static::text('common.snapshot'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
|
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
|
||||||
@ -211,8 +236,8 @@ public static function infolist(Schema $schema): Schema
|
|||||||
$status = $meta['original_status'] ?? null;
|
$status = $meta['original_status'] ?? null;
|
||||||
|
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'Graph returned %s for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
|
static::text('resource.snapshot_metadata_only_helper'),
|
||||||
$status ?? 'an error'
|
$status ?? static::text('resource.graph_error_fallback')
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||||
@ -225,7 +250,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->activeTab(1)
|
->activeTab(1)
|
||||||
->persistTabInQueryString()
|
->persistTabInQueryString()
|
||||||
->tabs([
|
->tabs([
|
||||||
Tab::make('General')
|
Tab::make(static::text('resource.tab_general'))
|
||||||
->id('general')
|
->id('general')
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('policy_general')
|
ViewEntry::make('policy_general')
|
||||||
@ -236,7 +261,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||||
Tab::make('Settings')
|
Tab::make(static::text('common.settings'))
|
||||||
->id('settings')
|
->id('settings')
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('settings')
|
ViewEntry::make('settings')
|
||||||
@ -248,12 +273,12 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||||
|
|
||||||
TextEntry::make('no_settings_available')
|
TextEntry::make('no_settings_available')
|
||||||
->label('Settings')
|
->label(static::text('common.settings'))
|
||||||
->state('No policy snapshot available yet.')
|
->state(static::text('resource.settings_empty_state'))
|
||||||
->helperText('This policy has been inventoried but no configuration snapshot has been captured yet.')
|
->helperText(static::text('resource.settings_empty_state_helper'))
|
||||||
->visible(fn (Policy $record) => ! $record->versions()->exists()),
|
->visible(fn (Policy $record) => ! $record->versions()->exists()),
|
||||||
]),
|
]),
|
||||||
Tab::make('JSON')
|
Tab::make(static::text('resource.tab_json'))
|
||||||
->id('json')
|
->id('json')
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('snapshot_json')
|
ViewEntry::make('snapshot_json')
|
||||||
@ -261,7 +286,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->state(fn (Policy $record) => static::latestSnapshot($record))
|
->state(fn (Policy $record) => static::latestSnapshot($record))
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
TextEntry::make('snapshot_size')
|
TextEntry::make('snapshot_size')
|
||||||
->label('Payload Size')
|
->label(static::text('resource.payload_size'))
|
||||||
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
||||||
->formatStateUsing(function ($state) {
|
->formatStateUsing(function ($state) {
|
||||||
if ($state > 512000) {
|
if ($state > 512000) {
|
||||||
@ -269,7 +294,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||||
</svg>
|
</svg>
|
||||||
Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance
|
'.static::text('resource.large_payload_warning', ['size' => number_format($state / 1024, 0)]).'
|
||||||
</span>';
|
</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,7 +309,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->visible(fn (Policy $record) => static::usesTabbedLayout($record)),
|
->visible(fn (Policy $record) => static::usesTabbedLayout($record)),
|
||||||
|
|
||||||
// Legacy layout (kept for fallback if tabs are disabled)
|
// Legacy layout (kept for fallback if tabs are disabled)
|
||||||
Section::make('Settings')
|
Section::make(static::text('common.settings'))
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('settings')
|
ViewEntry::make('settings')
|
||||||
->label('')
|
->label('')
|
||||||
@ -298,7 +323,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
return ! static::usesTabbedLayout($record);
|
return ! static::usesTabbedLayout($record);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
Section::make('Policy Snapshot (JSON)')
|
Section::make(static::text('resource.snapshot_json_section'))
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('snapshot_json')
|
ViewEntry::make('snapshot_json')
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
@ -306,7 +331,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
TextEntry::make('snapshot_size')
|
TextEntry::make('snapshot_size')
|
||||||
->label('Payload Size')
|
->label(static::text('resource.payload_size'))
|
||||||
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
||||||
->formatStateUsing(function ($state) {
|
->formatStateUsing(function ($state) {
|
||||||
if ($state > 512000) {
|
if ($state > 512000) {
|
||||||
@ -314,7 +339,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||||
</svg>
|
</svg>
|
||||||
Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance
|
'.static::text('resource.large_payload_warning', ['size' => number_format($state / 1024, 0)]).'
|
||||||
</span>';
|
</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -336,11 +361,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(function (Builder $query) {
|
|
||||||
// Quick-Workaround: Hide policies not synced in last 7 days
|
|
||||||
// Full solution in Feature 005: Policy Lifecycle Management (soft delete)
|
|
||||||
$query->where('last_synced_at', '>', now()->subDays(7));
|
|
||||||
})
|
|
||||||
->defaultSort('display_name')
|
->defaultSort('display_name')
|
||||||
->paginated(TablePaginationProfiles::resource())
|
->paginated(TablePaginationProfiles::resource())
|
||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
@ -349,24 +369,36 @@ public static function table(Table $table): Table
|
|||||||
->recordUrl(fn (Policy $record): string => static::getUrl('view', ['record' => $record]))
|
->recordUrl(fn (Policy $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('display_name')
|
Tables\Columns\TextColumn::make('display_name')
|
||||||
->label('Policy')
|
->label(static::text('common.policy'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('policy_type')
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
->label('Type')
|
->label(static::text('common.type'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||||
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
||||||
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType)),
|
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType)),
|
||||||
|
Tables\Columns\TextColumn::make('visibility_state')
|
||||||
|
->label(static::text('common.visibility'))
|
||||||
|
->badge()
|
||||||
|
->state(fn (Policy $record): string => $record->visibilityState())
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyProviderPresence))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::PolicyProviderPresence))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicyProviderPresence))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyProviderPresence))
|
||||||
|
->description(fn (Policy $record): ?string => $record->isProviderMissing()
|
||||||
|
? static::text('resource.visibility_source_unavailable_description')
|
||||||
|
: null),
|
||||||
Tables\Columns\TextColumn::make('category')
|
Tables\Columns\TextColumn::make('category')
|
||||||
->label('Category')
|
->label(static::text('common.category'))
|
||||||
->badge()
|
->badge()
|
||||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? null)
|
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? null)
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('restore_mode')
|
Tables\Columns\TextColumn::make('restore_mode')
|
||||||
->label('Restore')
|
->label(static::text('common.restore'))
|
||||||
->badge()
|
->badge()
|
||||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
|
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
|
||||||
@ -374,19 +406,22 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
|
||||||
Tables\Columns\TextColumn::make('platform')
|
Tables\Columns\TextColumn::make('platform')
|
||||||
|
->label(static::text('common.platform'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('settings_status')
|
Tables\Columns\TextColumn::make('settings_status')
|
||||||
->label('Settings')
|
->label(static::text('common.settings'))
|
||||||
->badge()
|
->badge()
|
||||||
->state(function (Policy $record) {
|
->state(function (Policy $record) {
|
||||||
$latest = $record->versions->first();
|
$latest = $record->versions->first();
|
||||||
$snapshot = $latest?->snapshot ?? [];
|
$snapshot = $latest?->snapshot ?? [];
|
||||||
$hasSettings = is_array($snapshot) && ! empty($snapshot['settings']);
|
$hasSettings = is_array($snapshot) && ! empty($snapshot['settings']);
|
||||||
|
|
||||||
return $hasSettings ? 'Available' : 'Missing';
|
return $hasSettings
|
||||||
|
? static::text('resource.settings_available')
|
||||||
|
: static::text('resource.settings_missing');
|
||||||
})
|
})
|
||||||
->color(function (Policy $record) {
|
->color(function (Policy $record) {
|
||||||
$latest = $record->versions->first();
|
$latest = $record->versions->first();
|
||||||
@ -396,12 +431,12 @@ public static function table(Table $table): Table
|
|||||||
return $hasSettings ? 'success' : 'gray';
|
return $hasSettings ? 'success' : 'gray';
|
||||||
}),
|
}),
|
||||||
Tables\Columns\TextColumn::make('external_id')
|
Tables\Columns\TextColumn::make('external_id')
|
||||||
->label('External ID')
|
->label(static::text('common.external_id'))
|
||||||
->copyable()
|
->copyable()
|
||||||
->limit(32)
|
->limit(32)
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('last_synced_at')
|
Tables\Columns\TextColumn::make('last_synced_at')
|
||||||
->label('Last synced')
|
->label(static::text('common.last_synced'))
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
@ -411,27 +446,35 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('visibility')
|
Tables\Filters\SelectFilter::make('visibility')
|
||||||
->label('Visibility')
|
->label(static::text('common.visibility'))
|
||||||
->options([
|
->options([
|
||||||
'active' => 'Active',
|
'active' => static::text('resource.filter_active'),
|
||||||
'ignored' => 'Ignored',
|
'ignored' => static::text('resource.filter_ignored'),
|
||||||
|
'provider_missing' => static::text('resource.filter_source_unavailable'),
|
||||||
|
'all' => static::text('resource.filter_all'),
|
||||||
])
|
])
|
||||||
->default('active')
|
->default('active')
|
||||||
->query(function (Builder $query, array $data) {
|
->query(function (Builder $query, array $data) {
|
||||||
$value = $data['value'] ?? null;
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
if (blank($value)) {
|
if (blank($value) || $value === 'all') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($value === 'active') {
|
if ($value === 'active') {
|
||||||
$query->whereNull('ignored_at');
|
$query->active();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($value === 'ignored') {
|
if ($value === 'ignored') {
|
||||||
$query->whereNotNull('ignored_at');
|
$query->whereNotNull('ignored_at');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === 'provider_missing') {
|
||||||
|
$query->whereNotNull('missing_from_provider_at');
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Tables\Filters\SelectFilter::make('policy_type')
|
Tables\Filters\SelectFilter::make('policy_type')
|
||||||
@ -475,14 +518,16 @@ public static function table(Table $table): Table
|
|||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('export')
|
Actions\Action::make('export')
|
||||||
->label('Export to Backup')
|
->label(static::text('resource.export_to_backup'))
|
||||||
->icon('heroicon-o-archive-box-arrow-down')
|
->icon('heroicon-o-archive-box-arrow-down')
|
||||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||||
|
->disabled(fn (Policy $record): bool => ! $record->isCurrentBackupEligible())
|
||||||
|
->tooltip(fn (Policy $record): ?string => $record->currentBackupBlockedReasonLabel())
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\TextInput::make('backup_name')
|
Forms\Components\TextInput::make('backup_name')
|
||||||
->label('Backup Name')
|
->label(static::text('common.backup_name'))
|
||||||
->required()
|
->required()
|
||||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
->default(fn () => static::text('common.backup_name_default_prefix').' '.now()->toDateTimeString()),
|
||||||
])
|
])
|
||||||
->action(function (Policy $record, array $data): void {
|
->action(function (Policy $record, array $data): void {
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
@ -496,6 +541,16 @@ public static function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $record->isCurrentBackupEligible()) {
|
||||||
|
Notification::make()
|
||||||
|
->title(static::text('resource.current_backup_unavailable'))
|
||||||
|
->body($record->currentBackupBlockedReasonLabel())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$ids = [(int) $record->getKey()];
|
$ids = [(int) $record->getKey()];
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -533,7 +588,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -541,11 +596,12 @@ public static function table(Table $table): Table
|
|||||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->preserveDisabled()
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('sync')
|
Actions\Action::make('sync')
|
||||||
->label('Sync')
|
->label(static::text('resource.sync_action_secondary'))
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -579,7 +635,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -592,7 +648,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -604,7 +660,7 @@ public static function table(Table $table): Table
|
|||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('restore')
|
Actions\Action::make('restore')
|
||||||
->label('Restore')
|
->label(static::text('resource.restore_action'))
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('success')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -613,19 +669,19 @@ public static function table(Table $table): Table
|
|||||||
$record->unignore();
|
$record->unignore();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy restored')
|
->title(static::text('resource.policy_restored'))
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->tooltip('You do not have permission to restore policies.')
|
->tooltip(static::text('resource.restore_permission_tooltip'))
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('ignore')
|
Actions\Action::make('ignore')
|
||||||
->label('Ignore')
|
->label(static::text('resource.ignore_action'))
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -634,31 +690,31 @@ public static function table(Table $table): Table
|
|||||||
$record->ignore();
|
$record->ignore();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy ignored')
|
->title(static::text('resource.policy_ignored'))
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->tooltip('You do not have permission to ignore policies.')
|
->tooltip(static::text('resource.ignore_permission_tooltip'))
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply(),
|
||||||
])
|
])
|
||||||
->label('More')
|
->label(static::text('common.more'))
|
||||||
->icon('heroicon-o-ellipsis-vertical'),
|
->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
UiEnforcement::forBulkAction(
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_export')
|
BulkAction::make('bulk_export')
|
||||||
->label('Export to Backup')
|
->label(static::text('resource.export_to_backup'))
|
||||||
->icon('heroicon-o-archive-box-arrow-down')
|
->icon('heroicon-o-archive-box-arrow-down')
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\TextInput::make('backup_name')
|
Forms\Components\TextInput::make('backup_name')
|
||||||
->label('Backup Name')
|
->label(static::text('common.backup_name'))
|
||||||
->required()
|
->required()
|
||||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
->default(fn () => static::text('common.backup_name_default_prefix').' '.now()->toDateTimeString()),
|
||||||
])
|
])
|
||||||
->action(function (Collection $records, array $data): void {
|
->action(function (Collection $records, array $data): void {
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
@ -674,6 +730,20 @@ public static function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$blocked = $records->first(
|
||||||
|
fn ($record): bool => $record instanceof Policy && ! $record->isCurrentBackupEligible()
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($blocked instanceof Policy) {
|
||||||
|
Notification::make()
|
||||||
|
->title(static::text('resource.current_backup_unavailable'))
|
||||||
|
->body($blocked->currentBackupBlockedReasonLabel())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
|
||||||
@ -721,7 +791,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -732,7 +802,7 @@ public static function table(Table $table): Table
|
|||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forBulkAction(
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_sync')
|
BulkAction::make('bulk_sync')
|
||||||
->label('Sync Policies')
|
->label(static::text('resource.sync_action_primary'))
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -779,7 +849,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -792,7 +862,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -803,7 +873,7 @@ public static function table(Table $table): Table
|
|||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forBulkAction(
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_restore')
|
BulkAction::make('bulk_restore')
|
||||||
->label('Restore Policies')
|
->label(static::text('resource.restore_bulk_action'))
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('success')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -873,7 +943,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -884,7 +954,7 @@ public static function table(Table $table): Table
|
|||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forBulkAction(
|
UiEnforcement::forBulkAction(
|
||||||
BulkAction::make('bulk_delete')
|
BulkAction::make('bulk_delete')
|
||||||
->label('Ignore Policies')
|
->label(static::text('resource.ignore_bulk_action'))
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -898,11 +968,11 @@ public static function table(Table $table): Table
|
|||||||
if ($records->count() >= 20) {
|
if ($records->count() >= 20) {
|
||||||
return [
|
return [
|
||||||
Forms\Components\TextInput::make('confirmation')
|
Forms\Components\TextInput::make('confirmation')
|
||||||
->label('Type DELETE to confirm')
|
->label(static::text('common.type_delete_to_confirm'))
|
||||||
->required()
|
->required()
|
||||||
->in(['DELETE'])
|
->in(['DELETE'])
|
||||||
->validationMessages([
|
->validationMessages([
|
||||||
'in' => 'Please type DELETE to confirm.',
|
'in' => static::text('common.type_delete_to_confirm_validation'),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -955,10 +1025,10 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||||
->body("Queued deletion for {$count} policies.")
|
->body(static::text('resource.delete_queued_body', ['count' => $count]))
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url($runUrl),
|
->url($runUrl),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -967,10 +1037,10 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->body("Queued deletion for {$count} policies.")
|
->body(static::text('resource.delete_queued_body', ['count' => $count]))
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url($runUrl),
|
->url($runUrl),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -979,10 +1049,10 @@ public static function table(Table $table): Table
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
])->label('More'),
|
])->label(static::text('common.more')),
|
||||||
])
|
])
|
||||||
->emptyStateHeading('No policies synced yet')
|
->emptyStateHeading(static::text('resource.empty_state_heading'))
|
||||||
->emptyStateDescription('Sync your first tenant to see Intune policies here.')
|
->emptyStateDescription(static::text('resource.empty_state_description'))
|
||||||
->emptyStateIcon('heroicon-o-arrow-path')
|
->emptyStateIcon('heroicon-o-arrow-path')
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
static::makeSyncAction(),
|
static::makeSyncAction(),
|
||||||
@ -1159,25 +1229,25 @@ private static function generalOverviewState(Policy $record): array
|
|||||||
|
|
||||||
$name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name;
|
$name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name;
|
||||||
if (is_string($name) && $name !== '') {
|
if (is_string($name) && $name !== '') {
|
||||||
$entries[] = ['key' => 'Name', 'value' => $name];
|
$entries[] = ['key' => static::text('resource.general_field_name'), 'value' => $name];
|
||||||
}
|
}
|
||||||
|
|
||||||
$platforms = $snapshot['platforms'] ?? $snapshot['platform'] ?? $record->platform;
|
$platforms = $snapshot['platforms'] ?? $snapshot['platform'] ?? $record->platform;
|
||||||
if (is_string($platforms) && $platforms !== '') {
|
if (is_string($platforms) && $platforms !== '') {
|
||||||
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
|
$entries[] = ['key' => static::text('resource.general_field_platforms'), 'value' => $platforms];
|
||||||
} elseif (is_array($platforms) && $platforms !== []) {
|
} elseif (is_array($platforms) && $platforms !== []) {
|
||||||
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
|
$entries[] = ['key' => static::text('resource.general_field_platforms'), 'value' => $platforms];
|
||||||
}
|
}
|
||||||
|
|
||||||
$technologies = $snapshot['technologies'] ?? null;
|
$technologies = $snapshot['technologies'] ?? null;
|
||||||
if (is_string($technologies) && $technologies !== '') {
|
if (is_string($technologies) && $technologies !== '') {
|
||||||
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
|
$entries[] = ['key' => static::text('resource.general_field_technologies'), 'value' => $technologies];
|
||||||
} elseif (is_array($technologies) && $technologies !== []) {
|
} elseif (is_array($technologies) && $technologies !== []) {
|
||||||
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
|
$entries[] = ['key' => static::text('resource.general_field_technologies'), 'value' => $technologies];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (array_key_exists('templateReference', $snapshot)) {
|
if (array_key_exists('templateReference', $snapshot)) {
|
||||||
$entries[] = ['key' => 'Template Reference', 'value' => $snapshot['templateReference']];
|
$entries[] = ['key' => static::text('resource.general_field_template_reference'), 'value' => $snapshot['templateReference']];
|
||||||
}
|
}
|
||||||
|
|
||||||
$settingCount = $snapshot['settingCount']
|
$settingCount = $snapshot['settingCount']
|
||||||
@ -1185,29 +1255,29 @@ private static function generalOverviewState(Policy $record): array
|
|||||||
?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null);
|
?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null);
|
||||||
|
|
||||||
if (is_int($settingCount) || is_numeric($settingCount)) {
|
if (is_int($settingCount) || is_numeric($settingCount)) {
|
||||||
$entries[] = ['key' => 'Setting Count', 'value' => $settingCount];
|
$entries[] = ['key' => static::text('resource.general_field_setting_count'), 'value' => $settingCount];
|
||||||
}
|
}
|
||||||
|
|
||||||
$version = $snapshot['version'] ?? null;
|
$version = $snapshot['version'] ?? null;
|
||||||
if (is_string($version) && $version !== '') {
|
if (is_string($version) && $version !== '') {
|
||||||
$entries[] = ['key' => 'Version', 'value' => $version];
|
$entries[] = ['key' => static::text('resource.general_field_version'), 'value' => $version];
|
||||||
} elseif (is_numeric($version)) {
|
} elseif (is_numeric($version)) {
|
||||||
$entries[] = ['key' => 'Version', 'value' => $version];
|
$entries[] = ['key' => static::text('resource.general_field_version'), 'value' => $version];
|
||||||
}
|
}
|
||||||
|
|
||||||
$lastModified = $snapshot['lastModifiedDateTime'] ?? null;
|
$lastModified = $snapshot['lastModifiedDateTime'] ?? null;
|
||||||
if (is_string($lastModified) && $lastModified !== '') {
|
if (is_string($lastModified) && $lastModified !== '') {
|
||||||
$entries[] = ['key' => 'Last Modified', 'value' => $lastModified];
|
$entries[] = ['key' => static::text('resource.general_field_last_modified'), 'value' => $lastModified];
|
||||||
}
|
}
|
||||||
|
|
||||||
$createdAt = $snapshot['createdDateTime'] ?? null;
|
$createdAt = $snapshot['createdDateTime'] ?? null;
|
||||||
if (is_string($createdAt) && $createdAt !== '') {
|
if (is_string($createdAt) && $createdAt !== '') {
|
||||||
$entries[] = ['key' => 'Created', 'value' => $createdAt];
|
$entries[] = ['key' => static::text('resource.general_field_created'), 'value' => $createdAt];
|
||||||
}
|
}
|
||||||
|
|
||||||
$description = $snapshot['description'] ?? null;
|
$description = $snapshot['description'] ?? null;
|
||||||
if (is_string($description) && $description !== '') {
|
if (is_string($description) && $description !== '') {
|
||||||
$entries[] = ['key' => 'Description', 'value' => $description];
|
$entries[] = ['key' => static::text('resource.general_field_description'), 'value' => $description];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -1232,4 +1302,9 @@ private static function settingsTabState(Policy $record): array
|
|||||||
|
|
||||||
return $normalized;
|
return $normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function text(string $key, array $replace = []): string
|
||||||
|
{
|
||||||
|
return __('localization.policy.'.$key, $replace);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
use App\Jobs\CapturePolicySnapshotJob;
|
use App\Jobs\CapturePolicySnapshotJob;
|
||||||
|
use App\Models\Policy;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
@ -39,23 +40,37 @@ private function makeCaptureSnapshotAction(): Action
|
|||||||
{
|
{
|
||||||
$action = UiEnforcement::forAction(
|
$action = UiEnforcement::forAction(
|
||||||
Action::make('capture_snapshot')
|
Action::make('capture_snapshot')
|
||||||
->label('Capture snapshot')
|
->label($this->text('resource.capture_snapshot_action'))
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('Capture snapshot now')
|
->modalHeading($this->text('resource.capture_snapshot_modal_heading'))
|
||||||
->modalSubheading('This queues a background job that fetches the latest configuration from Microsoft Graph and stores a new policy version.')
|
->modalSubheading($this->text('resource.capture_snapshot_modal_subheading').' '.$this->text('common.source_microsoft_intune'))
|
||||||
|
->disabled(fn (): bool => $this->record instanceof Policy && $this->record->isProviderMissing())
|
||||||
|
->tooltip(fn (): ?string => $this->record instanceof Policy && $this->record->isProviderMissing()
|
||||||
|
? $this->record->currentBackupBlockedReasonLabel()
|
||||||
|
: null)
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\Checkbox::make('include_assignments')
|
Forms\Components\Checkbox::make('include_assignments')
|
||||||
->label('Include assignments')
|
->label($this->text('resource.capture_snapshot_include_assignments'))
|
||||||
->default(true)
|
->default(true)
|
||||||
->helperText('Captures assignment include/exclude targeting and filters.'),
|
->helperText($this->text('resource.capture_snapshot_include_assignments_helper')),
|
||||||
Forms\Components\Checkbox::make('include_scope_tags')
|
Forms\Components\Checkbox::make('include_scope_tags')
|
||||||
->label('Include scope tags')
|
->label($this->text('resource.capture_snapshot_include_scope_tags'))
|
||||||
->default(true)
|
->default(true)
|
||||||
->helperText('Captures policy scope tag IDs.'),
|
->helperText($this->text('resource.capture_snapshot_include_scope_tags_helper')),
|
||||||
])
|
])
|
||||||
->action(function (array $data, AuditLogger $auditLogger) {
|
->action(function (array $data, AuditLogger $auditLogger) {
|
||||||
$policy = $this->record;
|
$policy = $this->record;
|
||||||
|
|
||||||
|
if ($policy instanceof Policy && $policy->isProviderMissing()) {
|
||||||
|
Notification::make()
|
||||||
|
->title($this->text('resource.capture_snapshot_unavailable_title'))
|
||||||
|
->body($policy->currentBackupBlockedReasonLabel())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = $policy->tenant;
|
$tenant = $policy->tenant;
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -108,11 +123,11 @@ private function makeCaptureSnapshotAction(): Action
|
|||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated) {
|
if (! $opRun->wasRecentlyCreated) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Snapshot already in progress')
|
->title($this->text('resource.capture_snapshot_in_progress_title'))
|
||||||
->body('An active run already exists for this policy. Opening run details.')
|
->body($this->text('resource.capture_snapshot_in_progress_body'))
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label($this->text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->info()
|
->info()
|
||||||
@ -145,7 +160,7 @@ private function makeCaptureSnapshotAction(): Action
|
|||||||
OperationUxPresenter::queuedToast('policy.capture_snapshot')
|
OperationUxPresenter::queuedToast('policy.capture_snapshot')
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label($this->text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -155,7 +170,8 @@ private function makeCaptureSnapshotAction(): Action
|
|||||||
->color('primary')
|
->color('primary')
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
->tooltip('You do not have permission to capture policy snapshots.')
|
->tooltip($this->text('resource.capture_snapshot_permission_tooltip'))
|
||||||
|
->preserveDisabled()
|
||||||
->apply();
|
->apply();
|
||||||
|
|
||||||
if (! $action instanceof Action) {
|
if (! $action instanceof Action) {
|
||||||
@ -164,4 +180,9 @@ private function makeCaptureSnapshotAction(): Action
|
|||||||
|
|
||||||
return $action;
|
return $action;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function text(string $key, array $replace = []): string
|
||||||
|
{
|
||||||
|
return __('localization.policy.'.$key, $replace);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,15 +59,15 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$restoreToIntune = Actions\Action::make('restore_to_intune')
|
$restoreToIntune = Actions\Action::make('restore_to_intune')
|
||||||
->label('Restore to Intune')
|
->label($this->text('relation.restore_to_microsoft_intune'))
|
||||||
->icon('heroicon-o-arrow-path-rounded-square')
|
->icon('heroicon-o-arrow-path-rounded-square')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
->modalHeading(fn (PolicyVersion $record): string => $this->text('relation.restore_heading', ['version' => $record->version_number]))
|
||||||
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
->modalSubheading($this->text('relation.restore_subheading'))
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\Toggle::make('is_dry_run')
|
Forms\Components\Toggle::make('is_dry_run')
|
||||||
->label('Preview only (dry-run)')
|
->label($this->text('common.preview_only_dry_run'))
|
||||||
->default(true),
|
->default(true),
|
||||||
])
|
])
|
||||||
->action(function (mixed $record, array $data, RestoreService $restoreService) {
|
->action(function (mixed $record, array $data, RestoreService $restoreService) {
|
||||||
@ -77,7 +77,7 @@ public function table(Table $table): Table
|
|||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Missing tenant or user context.')
|
->title($this->text('relation.missing_context_title'))
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ public function table(Table $table): Table
|
|||||||
|
|
||||||
if ($record->tenant_id !== $tenant->id) {
|
if ($record->tenant_id !== $tenant->id) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy version belongs to a different tenant')
|
->title($this->text('versions.different_tenant_title'))
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -103,7 +103,7 @@ public function table(Table $table): Table
|
|||||||
);
|
);
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Restore run failed to start')
|
->title($this->text('relation.restore_run_failed_title'))
|
||||||
->body($throwable->getMessage())
|
->body($throwable->getMessage())
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
@ -112,7 +112,7 @@ public function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Restore run started')
|
->title($this->text('relation.restore_run_started_title'))
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -146,7 +146,7 @@ public function table(Table $table): Table
|
|||||||
})
|
})
|
||||||
->tooltip(function (PolicyVersion $record): ?string {
|
->tooltip(function (PolicyVersion $record): ?string {
|
||||||
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||||
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
return $this->text('versions.metadata_only_tooltip');
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
@ -171,10 +171,11 @@ public function table(Table $table): Table
|
|||||||
|
|
||||||
return $table
|
return $table
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
Tables\Columns\TextColumn::make('version_number')->label($this->text('common.version'))->sortable(),
|
||||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
Tables\Columns\TextColumn::make('captured_at')->label($this->text('common.captured'))->dateTime()->sortable(),
|
||||||
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
Tables\Columns\TextColumn::make('created_by')->label($this->text('common.actor')),
|
||||||
Tables\Columns\TextColumn::make('policy_type')
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
|
->label($this->text('common.type'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||||
@ -189,8 +190,8 @@ public function table(Table $table): Table
|
|||||||
$restoreToIntune,
|
$restoreToIntune,
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No versions captured')
|
->emptyStateHeading($this->text('relation.no_versions_captured'))
|
||||||
->emptyStateDescription('Capture or sync this policy again to create version history entries.');
|
->emptyStateDescription($this->text('relation.no_versions_captured_description'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record): PolicyVersion
|
private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record): PolicyVersion
|
||||||
@ -214,4 +215,9 @@ private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record):
|
|||||||
|
|
||||||
return $resolvedRecord;
|
return $resolvedRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function text(string $key, array $replace = []): string
|
||||||
|
{
|
||||||
|
return __('localization.policy.'.$key, $replace);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -121,23 +121,25 @@ public static function infolist(Schema $schema): Schema
|
|||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('policy.display_name')
|
Infolists\Components\TextEntry::make('policy.display_name')
|
||||||
->label('Policy')
|
->label(static::text('common.policy'))
|
||||||
->state(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
|
->state(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
|
||||||
Infolists\Components\TextEntry::make('version_number')->label('Version'),
|
Infolists\Components\TextEntry::make('version_number')->label(static::text('common.version')),
|
||||||
Infolists\Components\TextEntry::make('policy_type')
|
Infolists\Components\TextEntry::make('policy_type')
|
||||||
|
->label(static::text('common.type'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||||
Infolists\Components\TextEntry::make('platform')
|
Infolists\Components\TextEntry::make('platform')
|
||||||
|
->label(static::text('common.platform'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||||
Infolists\Components\TextEntry::make('created_by')->label('Actor'),
|
Infolists\Components\TextEntry::make('created_by')->label(static::text('common.actor')),
|
||||||
Infolists\Components\TextEntry::make('captured_at')->dateTime(),
|
Infolists\Components\TextEntry::make('captured_at')->dateTime()->label(static::text('common.captured')),
|
||||||
Section::make('Backup quality')
|
Section::make(static::text('versions.backup_quality_section'))
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('quality_snapshot_mode')
|
Infolists\Components\TextEntry::make('quality_snapshot_mode')
|
||||||
->label('Snapshot')
|
->label(static::text('common.snapshot'))
|
||||||
->badge()
|
->badge()
|
||||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
|
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||||
@ -145,27 +147,27 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
|
||||||
Infolists\Components\TextEntry::make('quality_summary')
|
Infolists\Components\TextEntry::make('quality_summary')
|
||||||
->label('Backup quality')
|
->label(static::text('versions.backup_quality'))
|
||||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary),
|
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary),
|
||||||
Infolists\Components\TextEntry::make('quality_assignment_signal')
|
Infolists\Components\TextEntry::make('quality_assignment_signal')
|
||||||
->label('Assignment quality')
|
->label(static::text('versions.assignment_quality'))
|
||||||
->state(fn (PolicyVersion $record): string => static::policyVersionAssignmentQualityLabel($record)),
|
->state(fn (PolicyVersion $record): string => static::policyVersionAssignmentQualityLabel($record)),
|
||||||
Infolists\Components\TextEntry::make('quality_next_action')
|
Infolists\Components\TextEntry::make('quality_next_action')
|
||||||
->label('Next action')
|
->label(static::text('versions.next_action'))
|
||||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction),
|
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction),
|
||||||
Infolists\Components\TextEntry::make('quality_integrity_warning')
|
Infolists\Components\TextEntry::make('quality_integrity_warning')
|
||||||
->label('Integrity note')
|
->label(static::text('versions.integrity_note'))
|
||||||
->state(fn (PolicyVersion $record): ?string => static::policyVersionQualitySummary($record)->integrityWarning)
|
->state(fn (PolicyVersion $record): ?string => static::policyVersionQualitySummary($record)->integrityWarning)
|
||||||
->visible(fn (PolicyVersion $record): bool => static::policyVersionQualitySummary($record)->hasIntegrityWarning())
|
->visible(fn (PolicyVersion $record): bool => static::policyVersionQualitySummary($record)->hasIntegrityWarning())
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Infolists\Components\TextEntry::make('quality_boundary')
|
Infolists\Components\TextEntry::make('quality_boundary')
|
||||||
->label('Boundary')
|
->label(static::text('versions.boundary'))
|
||||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->positiveClaimBoundary)
|
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->positiveClaimBoundary)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Section::make('Related context')
|
Section::make(static::text('versions.related_context_section'))
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\ViewEntry::make('related_context')
|
Infolists\Components\ViewEntry::make('related_context')
|
||||||
->label('')
|
->label('')
|
||||||
@ -179,7 +181,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->persistTabInQueryString('tab')
|
->persistTabInQueryString('tab')
|
||||||
->columnSpanFull()
|
->columnSpanFull()
|
||||||
->tabs([
|
->tabs([
|
||||||
Tab::make('Normalized settings')
|
Tab::make(static::text('common.settings'))
|
||||||
->id('normalized-settings')
|
->id('normalized-settings')
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\ViewEntry::make('normalized_settings')
|
Infolists\Components\ViewEntry::make('normalized_settings')
|
||||||
@ -198,14 +200,14 @@ public static function infolist(Schema $schema): Schema
|
|||||||
return NormalizedSettingsSurface::build($normalized, 'policy_version');
|
return NormalizedSettingsSurface::build($normalized, 'policy_version');
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
Tab::make('Raw JSON')
|
Tab::make(static::text('resource.tab_json'))
|
||||||
->id('raw-json')
|
->id('raw-json')
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\ViewEntry::make('snapshot_pretty')
|
Infolists\Components\ViewEntry::make('snapshot_pretty')
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
->state(fn (PolicyVersion $record) => $record->snapshot ?? []),
|
->state(fn (PolicyVersion $record) => $record->snapshot ?? []),
|
||||||
]),
|
]),
|
||||||
Tab::make('Diff')
|
Tab::make(static::text('versions.diff_tab'))
|
||||||
->id('diff')
|
->id('diff')
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\ViewEntry::make('normalized_diff')
|
Infolists\Components\ViewEntry::make('normalized_diff')
|
||||||
@ -226,7 +228,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
return NormalizedDiffSurface::build($result, 'policy_version');
|
return NormalizedDiffSurface::build($result, 'policy_version');
|
||||||
}),
|
}),
|
||||||
Infolists\Components\ViewEntry::make('diff_json')
|
Infolists\Components\ViewEntry::make('diff_json')
|
||||||
->label('Raw diff (advanced)')
|
->label(static::text('versions.raw_diff_advanced'))
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
->state(function (PolicyVersion $record) {
|
->state(function (PolicyVersion $record) {
|
||||||
$previous = $record->previous();
|
$previous = $record->previous();
|
||||||
@ -275,11 +277,11 @@ public static function infolist(Schema $schema): Schema
|
|||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$bulkPruneVersions = BulkAction::make('bulk_prune_versions')
|
$bulkPruneVersions = BulkAction::make('bulk_prune_versions')
|
||||||
->label('Prune Versions')
|
->label(static::text('versions.prune_versions'))
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
|
->modalDescription(static::text('versions.prune_modal_description'))
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -291,8 +293,8 @@ public static function table(Table $table): Table
|
|||||||
->form(function (Collection $records) {
|
->form(function (Collection $records) {
|
||||||
$fields = [
|
$fields = [
|
||||||
Forms\Components\TextInput::make('retention_days')
|
Forms\Components\TextInput::make('retention_days')
|
||||||
->label('Retention Days')
|
->label(static::text('versions.retention_days'))
|
||||||
->helperText('Versions captured within the last N days will be skipped.')
|
->helperText(static::text('versions.retention_days_helper'))
|
||||||
->numeric()
|
->numeric()
|
||||||
->required()
|
->required()
|
||||||
->default(90)
|
->default(90)
|
||||||
@ -301,11 +303,11 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
if ($records->count() >= 20) {
|
if ($records->count() >= 20) {
|
||||||
$fields[] = Forms\Components\TextInput::make('confirmation')
|
$fields[] = Forms\Components\TextInput::make('confirmation')
|
||||||
->label('Type DELETE to confirm')
|
->label(static::text('common.type_delete_to_confirm'))
|
||||||
->required()
|
->required()
|
||||||
->in(['DELETE'])
|
->in(['DELETE'])
|
||||||
->validationMessages([
|
->validationMessages([
|
||||||
'in' => 'Please type DELETE to confirm.',
|
'in' => static::text('common.type_delete_to_confirm_validation'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -363,7 +365,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::queuedToast('policy_version.prune')
|
OperationUxPresenter::queuedToast('policy_version.prune')
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -372,11 +374,11 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
UiEnforcement::forBulkAction($bulkPruneVersions)
|
UiEnforcement::forBulkAction($bulkPruneVersions)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->tooltip('You do not have permission to manage policy versions.')
|
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||||
->apply();
|
->apply();
|
||||||
|
|
||||||
$bulkRestoreVersions = BulkAction::make('bulk_restore_versions')
|
$bulkRestoreVersions = BulkAction::make('bulk_restore_versions')
|
||||||
->label('Restore Versions')
|
->label(static::text('versions.restore_versions'))
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('success')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -388,8 +390,8 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return ! $isOnlyTrashed;
|
return ! $isOnlyTrashed;
|
||||||
})
|
})
|
||||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
|
->modalHeading(fn (Collection $records) => static::text('versions.restore_versions_modal_heading', ['count' => $records->count()]))
|
||||||
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
|
->modalDescription(static::text('versions.restore_versions_modal_description'))
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -438,7 +440,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::queuedToast('policy_version.restore')
|
OperationUxPresenter::queuedToast('policy_version.restore')
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -447,11 +449,11 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
UiEnforcement::forBulkAction($bulkRestoreVersions)
|
UiEnforcement::forBulkAction($bulkRestoreVersions)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->tooltip('You do not have permission to manage policy versions.')
|
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||||
->apply();
|
->apply();
|
||||||
|
|
||||||
$bulkForceDeleteVersions = BulkAction::make('bulk_force_delete_versions')
|
$bulkForceDeleteVersions = BulkAction::make('bulk_force_delete_versions')
|
||||||
->label('Force Delete Versions')
|
->label(static::text('versions.force_delete_versions'))
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -463,15 +465,15 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return ! $isOnlyTrashed;
|
return ! $isOnlyTrashed;
|
||||||
})
|
})
|
||||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
|
->modalHeading(fn (Collection $records) => static::text('versions.force_delete_versions_modal_heading', ['count' => $records->count()]))
|
||||||
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
|
->modalDescription(static::text('versions.force_delete_versions_modal_description'))
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\TextInput::make('confirmation')
|
Forms\Components\TextInput::make('confirmation')
|
||||||
->label('Type DELETE to confirm')
|
->label(static::text('common.type_delete_to_confirm'))
|
||||||
->required()
|
->required()
|
||||||
->in(['DELETE'])
|
->in(['DELETE'])
|
||||||
->validationMessages([
|
->validationMessages([
|
||||||
'in' => 'Please type DELETE to confirm.',
|
'in' => static::text('common.type_delete_to_confirm_validation'),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->action(function (Collection $records, array $data) {
|
->action(function (Collection $records, array $data) {
|
||||||
@ -522,7 +524,7 @@ public static function table(Table $table): Table
|
|||||||
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('Open operation')
|
->label(static::text('common.open_operation'))
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
@ -531,7 +533,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
UiEnforcement::forBulkAction($bulkForceDeleteVersions)
|
UiEnforcement::forBulkAction($bulkForceDeleteVersions)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->tooltip('You do not have permission to manage policy versions.')
|
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||||
->apply();
|
->apply();
|
||||||
|
|
||||||
return $table
|
return $table
|
||||||
@ -542,13 +544,15 @@ public static function table(Table $table): Table
|
|||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('policy.display_name')
|
Tables\Columns\TextColumn::make('policy.display_name')
|
||||||
->label('Policy')
|
->label(static::text('common.policy'))
|
||||||
->sortable()
|
->sortable()
|
||||||
->searchable()
|
->searchable()
|
||||||
->getStateUsing(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
|
->getStateUsing(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
|
||||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
Tables\Columns\TextColumn::make('version_number')
|
||||||
|
->label(static::text('common.version'))
|
||||||
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('snapshot_mode')
|
Tables\Columns\TextColumn::make('snapshot_mode')
|
||||||
->label('Snapshot')
|
->label(static::text('common.snapshot'))
|
||||||
->badge()
|
->badge()
|
||||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
|
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||||
@ -556,30 +560,33 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
|
||||||
Tables\Columns\TextColumn::make('backup_quality')
|
Tables\Columns\TextColumn::make('backup_quality')
|
||||||
->label('Backup quality')
|
->label(static::text('versions.backup_quality'))
|
||||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary)
|
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary)
|
||||||
->description(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction)
|
->description(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction)
|
||||||
->wrap(),
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('policy_type')
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
|
->label(static::text('common.type'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||||
Tables\Columns\TextColumn::make('platform')
|
Tables\Columns\TextColumn::make('platform')
|
||||||
|
->label(static::text('common.platform'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||||
Tables\Columns\TextColumn::make('created_by')->label('Actor')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('created_by')->label(static::text('common.actor'))->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
Tables\Columns\TextColumn::make('captured_at')->label(static::text('common.captured'))->dateTime()->sortable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('policy_type')
|
Tables\Filters\SelectFilter::make('policy_type')
|
||||||
->label('Type')
|
->label(static::text('common.type'))
|
||||||
->options(FilterOptionCatalog::policyTypes())
|
->options(FilterOptionCatalog::policyTypes())
|
||||||
->searchable(),
|
->searchable(),
|
||||||
Tables\Filters\SelectFilter::make('platform')
|
Tables\Filters\SelectFilter::make('platform')
|
||||||
|
->label(static::text('common.platform'))
|
||||||
->options(FilterOptionCatalog::platforms())
|
->options(FilterOptionCatalog::platforms())
|
||||||
->searchable(),
|
->searchable(),
|
||||||
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
FilterPresets::dateRange('captured_at', static::text('common.captured'), 'captured_at'),
|
||||||
FilterPresets::archived(),
|
FilterPresets::archived(),
|
||||||
])
|
])
|
||||||
->recordUrl(static fn (PolicyVersion $record): ?string => static::canView($record)
|
->recordUrl(static fn (PolicyVersion $record): ?string => static::canView($record)
|
||||||
@ -590,12 +597,12 @@ public static function table(Table $table): Table
|
|||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
(function (): Actions\Action {
|
(function (): Actions\Action {
|
||||||
$action = Actions\Action::make('restore_via_wizard')
|
$action = Actions\Action::make('restore_via_wizard')
|
||||||
->label('Restore via Wizard')
|
->label(static::text('versions.restore_via_wizard'))
|
||||||
->icon('heroicon-o-arrow-path-rounded-square')
|
->icon('heroicon-o-arrow-path-rounded-square')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
|
->modalHeading(fn (PolicyVersion $record): string => static::text('versions.restore_via_wizard_modal_heading', ['version' => $record->version_number]))
|
||||||
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
|
->modalSubheading(static::text('versions.restore_via_wizard_modal_subheading'))
|
||||||
->visible(function (): bool {
|
->visible(function (): bool {
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -646,11 +653,11 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||||
return 'You do not have permission to create restore runs.';
|
return static::text('versions.restore_run_permission_tooltip');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
|
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
|
||||||
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
return static::text('versions.metadata_only_tooltip');
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@ -676,8 +683,8 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
|
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Restore disabled for metadata-only snapshot')
|
->title(static::text('versions.restore_disabled_metadata_title'))
|
||||||
->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.')
|
->body(static::text('versions.restore_disabled_metadata_body'))
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -686,7 +693,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
if (! $tenant || $record->tenant_id !== $tenant->id) {
|
if (! $tenant || $record->tenant_id !== $tenant->id) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy version belongs to a different tenant')
|
->title(static::text('versions.different_tenant_title'))
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -697,7 +704,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
if (! $policy) {
|
if (! $policy) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy could not be found for this version')
|
->title(static::text('versions.missing_policy_title'))
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -706,11 +713,10 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
$backupSet = BackupSet::create([
|
$backupSet = BackupSet::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'name' => sprintf(
|
'name' => static::text('versions.backup_set_name', [
|
||||||
'Policy Version Restore • %s • v%d',
|
'policy' => $policy->display_name,
|
||||||
$policy->display_name,
|
'version' => $record->version_number,
|
||||||
$record->version_number
|
]),
|
||||||
),
|
|
||||||
'created_by' => $user?->email,
|
'created_by' => $user?->email,
|
||||||
'status' => 'completed',
|
'status' => 'completed',
|
||||||
'item_count' => 1,
|
'item_count' => 1,
|
||||||
@ -788,7 +794,7 @@ public static function table(Table $table): Table
|
|||||||
})(),
|
})(),
|
||||||
(function (): Actions\Action {
|
(function (): Actions\Action {
|
||||||
$action = Actions\Action::make('archive')
|
$action = Actions\Action::make('archive')
|
||||||
->label('Archive')
|
->label(static::text('versions.archive'))
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -815,7 +821,7 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy version archived')
|
->title(static::text('versions.archived_title'))
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
});
|
});
|
||||||
@ -823,14 +829,14 @@ public static function table(Table $table): Table
|
|||||||
UiEnforcement::forAction($action)
|
UiEnforcement::forAction($action)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->tooltip('You do not have permission to manage policy versions.')
|
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||||
->apply();
|
->apply();
|
||||||
|
|
||||||
return $action;
|
return $action;
|
||||||
})(),
|
})(),
|
||||||
(function (): Actions\Action {
|
(function (): Actions\Action {
|
||||||
$action = Actions\Action::make('forceDelete')
|
$action = Actions\Action::make('forceDelete')
|
||||||
->label('Force delete')
|
->label(static::text('versions.force_delete'))
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -857,7 +863,7 @@ public static function table(Table $table): Table
|
|||||||
$record->forceDelete();
|
$record->forceDelete();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy version permanently deleted')
|
->title(static::text('versions.force_deleted_title'))
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
});
|
});
|
||||||
@ -865,7 +871,7 @@ public static function table(Table $table): Table
|
|||||||
UiEnforcement::forAction($action)
|
UiEnforcement::forAction($action)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->tooltip('You do not have permission to manage policy versions.')
|
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||||
->apply();
|
->apply();
|
||||||
|
|
||||||
return $action;
|
return $action;
|
||||||
@ -873,7 +879,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
(function (): Actions\Action {
|
(function (): Actions\Action {
|
||||||
$action = Actions\Action::make('restore')
|
$action = Actions\Action::make('restore')
|
||||||
->label('Restore')
|
->label(static::text('common.restore'))
|
||||||
->color('success')
|
->color('success')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -900,7 +906,7 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy version restored')
|
->title(static::text('versions.restored_title'))
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
});
|
});
|
||||||
@ -908,13 +914,13 @@ public static function table(Table $table): Table
|
|||||||
UiEnforcement::forAction($action)
|
UiEnforcement::forAction($action)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->tooltip('You do not have permission to manage policy versions.')
|
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||||
->apply();
|
->apply();
|
||||||
|
|
||||||
return $action;
|
return $action;
|
||||||
})(),
|
})(),
|
||||||
])
|
])
|
||||||
->label('More')
|
->label(static::text('common.more'))
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
])
|
])
|
||||||
@ -923,14 +929,14 @@ public static function table(Table $table): Table
|
|||||||
$bulkPruneVersions,
|
$bulkPruneVersions,
|
||||||
$bulkRestoreVersions,
|
$bulkRestoreVersions,
|
||||||
$bulkForceDeleteVersions,
|
$bulkForceDeleteVersions,
|
||||||
])->label('More'),
|
])->label(static::text('common.more')),
|
||||||
])
|
])
|
||||||
->emptyStateHeading('No policy versions')
|
->emptyStateHeading(static::text('versions.empty_state_heading'))
|
||||||
->emptyStateDescription('Capture or sync policy snapshots to build a version history.')
|
->emptyStateDescription(static::text('versions.empty_state_description'))
|
||||||
->emptyStateIcon('heroicon-o-clock')
|
->emptyStateIcon('heroicon-o-clock')
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
Actions\Action::make('open_backup_sets')
|
Actions\Action::make('open_backup_sets')
|
||||||
->label('Open backup sets')
|
->label(static::text('versions.open_backup_sets'))
|
||||||
->url(fn (): string => BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
|
->url(fn (): string => BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
]);
|
]);
|
||||||
@ -1016,7 +1022,7 @@ public static function relatedContextEntries(PolicyVersion $record): array
|
|||||||
private static function primaryRelatedAction(): Actions\Action
|
private static function primaryRelatedAction(): Actions\Action
|
||||||
{
|
{
|
||||||
return Actions\Action::make('primary_drill_down')
|
return Actions\Action::make('primary_drill_down')
|
||||||
->label(fn (PolicyVersion $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
|
->label(fn (PolicyVersion $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? static::text('versions.related_record_fallback'))
|
||||||
->url(fn (PolicyVersion $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
|
->url(fn (PolicyVersion $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
|
||||||
->hidden(fn (PolicyVersion $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
|
->hidden(fn (PolicyVersion $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
|
||||||
->color('gray');
|
->color('gray');
|
||||||
@ -1032,10 +1038,10 @@ private static function policyVersionAssignmentQualityLabel(PolicyVersion $recor
|
|||||||
$summary = static::policyVersionQualitySummary($record);
|
$summary = static::policyVersionQualitySummary($record);
|
||||||
|
|
||||||
return match (true) {
|
return match (true) {
|
||||||
$summary->hasAssignmentIssues && $summary->hasOrphanedAssignments => 'Assignment fetch failed and orphaned targets were detected.',
|
$summary->hasAssignmentIssues && $summary->hasOrphanedAssignments => static::text('versions.assignment_fetch_failed_orphaned'),
|
||||||
$summary->hasAssignmentIssues => 'Assignment fetch failed during capture.',
|
$summary->hasAssignmentIssues => static::text('versions.assignment_fetch_failed'),
|
||||||
$summary->hasOrphanedAssignments => 'Orphaned assignment targets were detected.',
|
$summary->hasOrphanedAssignments => static::text('versions.assignment_orphaned'),
|
||||||
default => 'No assignment issues were detected from captured metadata.',
|
default => static::text('versions.assignment_no_issues'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1065,6 +1071,11 @@ private static function resolvedDisplayName(PolicyVersion $record): string
|
|||||||
return $displayName;
|
return $displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
return sprintf('Version %d', (int) $record->version_number);
|
return static::text('versions.fallback_display_name', ['version' => (int) $record->version_number]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function text(string $key, array $replace = []): string
|
||||||
|
{
|
||||||
|
return __('localization.policy.'.$key, $replace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1473,9 +1473,13 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
|||||||
->where(function ($query) {
|
->where(function ($query) {
|
||||||
$query->whereNull('policy_id')
|
$query->whereNull('policy_id')
|
||||||
->orWhereDoesntHave('policy')
|
->orWhereDoesntHave('policy')
|
||||||
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
->orWhereHas('policy', function ($policyQuery): void {
|
||||||
|
$policyQuery
|
||||||
|
->whereNull('ignored_at')
|
||||||
|
->orWhereNotNull('missing_from_provider_at');
|
||||||
|
});
|
||||||
})
|
})
|
||||||
->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at'])
|
->with(['policy:id,display_name,missing_from_provider_at,ignored_at', 'policyVersion:id,version_number,captured_at'])
|
||||||
->get()
|
->get()
|
||||||
->sortBy(function (BackupItem $item) {
|
->sortBy(function (BackupItem $item) {
|
||||||
$meta = static::typeMeta($item->policy_type);
|
$meta = static::typeMeta($item->policy_type);
|
||||||
@ -1499,6 +1503,9 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
|||||||
$displayName = $item->resolvedDisplayName();
|
$displayName = $item->resolvedDisplayName();
|
||||||
$identifier = $item->policy_identifier ?? null;
|
$identifier = $item->policy_identifier ?? null;
|
||||||
$versionNumber = $item->policyVersion?->version_number;
|
$versionNumber = $item->policyVersion?->version_number;
|
||||||
|
$providerMissingNote = $item->policy?->missing_from_provider_at
|
||||||
|
? 'current state: provider missing; historical restore available'
|
||||||
|
: null;
|
||||||
|
|
||||||
$options[$item->id] = $displayName;
|
$options[$item->id] = $displayName;
|
||||||
|
|
||||||
@ -1508,6 +1515,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
|||||||
$platform,
|
$platform,
|
||||||
'quality: '.$qualitySummary->compactSummary,
|
'quality: '.$qualitySummary->compactSummary,
|
||||||
"restore: {$restore}",
|
"restore: {$restore}",
|
||||||
|
$providerMissingNote,
|
||||||
$versionNumber ? "version: {$versionNumber}" : null,
|
$versionNumber ? "version: {$versionNumber}" : null,
|
||||||
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
|
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
|
||||||
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
|
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
|
||||||
@ -1540,9 +1548,13 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
|
|||||||
->where(function ($query) {
|
->where(function ($query) {
|
||||||
$query->whereNull('policy_id')
|
$query->whereNull('policy_id')
|
||||||
->orWhereDoesntHave('policy')
|
->orWhereDoesntHave('policy')
|
||||||
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
->orWhereHas('policy', function ($policyQuery): void {
|
||||||
|
$policyQuery
|
||||||
|
->whereNull('ignored_at')
|
||||||
|
->orWhereNotNull('missing_from_provider_at');
|
||||||
|
});
|
||||||
})
|
})
|
||||||
->with(['policy:id,display_name'])
|
->with(['policy:id,display_name,missing_from_provider_at,ignored_at'])
|
||||||
->get()
|
->get()
|
||||||
->sortBy(function (BackupItem $item) {
|
->sortBy(function (BackupItem $item) {
|
||||||
$meta = static::typeMeta($item->policy_type);
|
$meta = static::typeMeta($item->policy_type);
|
||||||
@ -1659,6 +1671,7 @@ private static function restoreItemSelectionLabel(BackupItem $item): string
|
|||||||
return implode(' • ', array_filter([
|
return implode(' • ', array_filter([
|
||||||
$item->resolvedDisplayName(),
|
$item->resolvedDisplayName(),
|
||||||
$summary->compactSummary,
|
$summary->compactSummary,
|
||||||
|
$item->policy?->missing_from_provider_at ? 'provider missing now' : null,
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -148,7 +148,8 @@ public static function infolist(Schema $schema): Schema
|
|||||||
TextEntry::make('file_size')
|
TextEntry::make('file_size')
|
||||||
->label('File size')
|
->label('File size')
|
||||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—'),
|
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—'),
|
||||||
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—'),
|
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—')
|
||||||
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
@ -184,6 +185,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
|
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
|
||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
Section::make('Metadata')
|
Section::make('Metadata')
|
||||||
@ -227,9 +229,12 @@ public static function infolist(Schema $schema): Schema
|
|||||||
return OperationRunLinks::tenantlessView((int) $record->operation_run_id);
|
return OperationRunLinks::tenantlessView((int) $record->operation_run_id);
|
||||||
})
|
})
|
||||||
->openUrlInNewTab()
|
->openUrlInNewTab()
|
||||||
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
|
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—')
|
||||||
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—'),
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||||
|
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—')
|
||||||
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||||
TextEntry::make('created_at')->label('Created')->dateTime(),
|
TextEntry::make('created_at')->label('Created')->dateTime(),
|
||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
@ -243,9 +248,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
TextEntry::make('evidenceSnapshot.id')
|
TextEntry::make('evidenceSnapshot.id')
|
||||||
->label('Snapshot')
|
->label('Snapshot')
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||||
->url(fn (ReviewPack $record): ?string => $record->evidenceSnapshot
|
->url(fn (ReviewPack $record): ?string => static::evidenceSnapshotUrl($record)),
|
||||||
? TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
|
||||||
: null),
|
|
||||||
TextEntry::make('evidenceSnapshot.completeness_state')
|
TextEntry::make('evidenceSnapshot.completeness_state')
|
||||||
->label('Snapshot completeness')
|
->label('Snapshot completeness')
|
||||||
->badge()
|
->badge()
|
||||||
@ -429,6 +432,36 @@ public static function getPages(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function isCustomerWorkspaceFlow(): bool
|
||||||
|
{
|
||||||
|
return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function evidenceSnapshotUrl(ReviewPack $record): ?string
|
||||||
|
{
|
||||||
|
if (! $record->evidenceSnapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant);
|
||||||
|
|
||||||
|
return static::isCustomerWorkspaceFlow()
|
||||||
|
? static::appendQuery($url, ['source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE])
|
||||||
|
: $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $query
|
||||||
|
*/
|
||||||
|
private static function appendQuery(string $url, array $query): string
|
||||||
|
{
|
||||||
|
if ($query === []) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||||
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(ReviewPack $record, bool $fresh = false): ArtifactTruthEnvelope
|
private static function truthEnvelope(ReviewPack $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
$presenter = app(ArtifactTruthPresenter::class);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|||||||
@ -19,6 +19,20 @@ class ViewReviewPack extends ViewRecord
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
|
if (ReviewPackResource::isCustomerWorkspaceFlow()) {
|
||||||
|
return [
|
||||||
|
Actions\Action::make('download')
|
||||||
|
->label('Download')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->color('primary')
|
||||||
|
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
|
||||||
|
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record, [
|
||||||
|
'source_surface' => \App\Filament\Pages\Reviews\CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||||
|
]))
|
||||||
|
->openUrlInNewTab(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$regenerateAction = UiEnforcement::forAction(
|
$regenerateAction = UiEnforcement::forAction(
|
||||||
Actions\Action::make('regenerate')
|
Actions\Action::make('regenerate')
|
||||||
->label('Regenerate')
|
->label('Regenerate')
|
||||||
|
|||||||
@ -213,6 +213,20 @@ public static function makeOpenInEntraAction(): Actions\Action
|
|||||||
->openUrlInNewTab();
|
->openUrlInNewTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function makeMembershipsAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('memberships')
|
||||||
|
->label('Manage memberships')
|
||||||
|
->icon('heroicon-o-users')
|
||||||
|
->url(fn (Tenant $record): string => static::getUrl('memberships', ['record' => $record], panel: 'admin')),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MEMBERSHIP_VIEW)
|
||||||
|
->tooltip('You do not have permission to view tenant memberships.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
public static function makeSyncTenantAction(): Actions\Action
|
public static function makeSyncTenantAction(): Actions\Action
|
||||||
{
|
{
|
||||||
return UiEnforcement::forAction(
|
return UiEnforcement::forAction(
|
||||||
|
|||||||
@ -2,7 +2,38 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
|
||||||
class ManageTenantMemberships extends ViewTenant
|
class ManageTenantMemberships extends ViewTenant
|
||||||
{
|
{
|
||||||
protected static ?string $title = 'Tenant memberships';
|
protected static ?string $title = 'Manage tenant memberships';
|
||||||
|
|
||||||
|
public function getSubheading(): ?string
|
||||||
|
{
|
||||||
|
return 'Tenant access is managed here. Use the tenant overview for provider state, verification, and operational context.';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$actions = array_values(array_filter(
|
||||||
|
parent::getHeaderActions(),
|
||||||
|
static fn ($action): bool => ! ($action instanceof Action && $action->getName() === 'memberships'),
|
||||||
|
));
|
||||||
|
|
||||||
|
array_unshift(
|
||||||
|
$actions,
|
||||||
|
Action::make('back_to_overview')
|
||||||
|
->label('Back to tenant overview')
|
||||||
|
->color('gray')
|
||||||
|
->url(TenantResource::getUrl('view', ['record' => $this->getRecord()->getRouteKey()], panel: 'admin')),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $actions;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,6 +54,7 @@ protected function getHeaderWidgets(): array
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return array_values(array_filter([
|
return array_values(array_filter([
|
||||||
|
TenantResource::makeMembershipsAction(),
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
TenantResource::makeAdminConsentAction(),
|
TenantResource::makeAdminConsentAction(),
|
||||||
TenantResource::makeOpenInEntraAction(),
|
TenantResource::makeOpenInEntraAction(),
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Filament\Resources\TenantReviewResource\Pages;
|
use App\Filament\Resources\TenantReviewResource\Pages;
|
||||||
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Models\TenantReviewSection;
|
use App\Models\TenantReviewSection;
|
||||||
@ -26,6 +27,7 @@
|
|||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
use App\Support\ReviewPackStatus;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\TenantReviewCompletenessState;
|
use App\Support\TenantReviewCompletenessState;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\TenantReviewStatus;
|
||||||
@ -215,6 +217,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
TextEntry::make('fingerprint')
|
TextEntry::make('fingerprint')
|
||||||
->copyable()
|
->copyable()
|
||||||
->placeholder('—')
|
->placeholder('—')
|
||||||
|
->hidden(fn (): bool => static::isCustomerWorkspaceMode())
|
||||||
->columnSpanFull()
|
->columnSpanFull()
|
||||||
->fontFamily('mono')
|
->fontFamily('mono')
|
||||||
->size(TextSize::ExtraSmall),
|
->size(TextSize::ExtraSmall),
|
||||||
@ -233,6 +236,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
Section::make(__('localization.review.sections'))
|
Section::make(__('localization.review.sections'))
|
||||||
->schema([
|
->schema([
|
||||||
RepeatableEntry::make('sections')
|
RepeatableEntry::make('sections')
|
||||||
|
->state(fn (TenantReview $record): array => static::visibleSections($record))
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('title'),
|
TextEntry::make('title'),
|
||||||
@ -262,6 +266,17 @@ public static function infolist(Schema $schema): Schema
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, TenantReviewSection>
|
||||||
|
*/
|
||||||
|
private static function visibleSections(TenantReview $record): array
|
||||||
|
{
|
||||||
|
return $record->sections
|
||||||
|
->reject(fn (TenantReviewSection $section): bool => static::isCustomerWorkspaceMode() && $section->isControlInterpretation())
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$exportExecutivePackAction = UiEnforcement::forTableAction(
|
$exportExecutivePackAction = UiEnforcement::forTableAction(
|
||||||
@ -639,6 +654,10 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
$highlights = is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [];
|
$highlights = is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [];
|
||||||
$findingOutcomeSummary = static::findingOutcomeSummary($summary);
|
$findingOutcomeSummary = static::findingOutcomeSummary($summary);
|
||||||
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
||||||
|
$controlInterpretation = is_array($summary['control_interpretation'] ?? null)
|
||||||
|
? $summary['control_interpretation']
|
||||||
|
: [];
|
||||||
|
$packagePresentation = static::governancePackagePresentation($record);
|
||||||
|
|
||||||
if ($findingOutcomeSummary !== null) {
|
if ($findingOutcomeSummary !== null) {
|
||||||
$highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.';
|
$highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.';
|
||||||
@ -647,12 +666,17 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
return [
|
return [
|
||||||
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
|
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
|
||||||
'compressed_outcome' => static::compressedOutcome($record)->toArray(),
|
'compressed_outcome' => static::compressedOutcome($record)->toArray(),
|
||||||
'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
|
'customer_workspace_mode' => static::isCustomerWorkspaceMode(),
|
||||||
|
'reason_semantics' => static::isCustomerWorkspaceMode()
|
||||||
|
? []
|
||||||
|
: $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
|
||||||
'highlights' => $highlights,
|
'highlights' => $highlights,
|
||||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||||
'context_links' => static::summaryContextLinks($record),
|
'context_links' => static::summaryContextLinks($record, static::isCustomerWorkspaceMode()),
|
||||||
'metrics' => [
|
'control_interpretation' => $controlInterpretation,
|
||||||
|
'governance_package' => $packagePresentation,
|
||||||
|
'metrics' => static::isCustomerWorkspaceMode() ? static::customerWorkspaceMetrics($record, $summary, $packagePresentation) : [
|
||||||
['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
|
['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||||
['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)],
|
['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||||
['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)],
|
['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)],
|
||||||
@ -664,13 +688,164 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array{title:string,label:string,url:string,description:string}>
|
* @param array<string, mixed> $summary
|
||||||
|
* @param array<string, mixed> $packagePresentation
|
||||||
|
* @return array<int, array{label:string,value:string}>
|
||||||
*/
|
*/
|
||||||
private static function summaryContextLinks(TenantReview $record): array
|
private static function customerWorkspaceMetrics(TenantReview $record, array $summary, array $packagePresentation): array
|
||||||
|
{
|
||||||
|
$acceptedRisk = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
['label' => __('localization.review.governance_package'), 'value' => (string) ($packagePresentation['availability']['label'] ?? __('localization.review.governance_package_unavailable'))],
|
||||||
|
['label' => __('localization.review.review_status'), 'value' => static::customerReviewStatusLabel($record)],
|
||||||
|
['label' => __('localization.review.evidence_status'), 'value' => static::customerEvidenceStatusLabel($record)],
|
||||||
|
['label' => __('localization.review.accepted_risk_status'), 'value' => static::customerAcceptedRiskStatusLabel($acceptedRisk)],
|
||||||
|
['label' => __('localization.review.last_review'), 'value' => $record->published_at?->format('Y-m-d') ?? __('localization.review.pending')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function customerReviewStatusLabel(TenantReview $record): string
|
||||||
|
{
|
||||||
|
if ($record->isPublished() && (string) $record->completeness_state === TenantReviewCompletenessState::Complete->value) {
|
||||||
|
return __('localization.review.review_completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->isPublished()) {
|
||||||
|
return __('localization.review.review_requires_attention');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Str::headline((string) $record->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function customerEvidenceStatusLabel(TenantReview $record): string
|
||||||
|
{
|
||||||
|
$snapshot = $record->evidenceSnapshot;
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||||
|
return __('localization.review.evidence_pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||||
|
return __('localization.review.evidence_restricted');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
||||||
|
return __('localization.review.evidence_expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
return __('localization.review.evidence_available');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $acceptedRisk
|
||||||
|
*/
|
||||||
|
private static function customerAcceptedRiskStatusLabel(array $acceptedRisk): string
|
||||||
|
{
|
||||||
|
$warningCount = (int) ($acceptedRisk['warning_count'] ?? 0);
|
||||||
|
$statusMarkedCount = (int) ($acceptedRisk['status_marked_count'] ?? 0);
|
||||||
|
|
||||||
|
if ($warningCount > 0) {
|
||||||
|
return __('localization.review.accepted_risk_follow_up');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($statusMarkedCount > 0) {
|
||||||
|
return __('localization.review.accepted_risk_on_record', ['count' => $statusMarkedCount]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return __('localization.review.accepted_risk_none');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function governancePackagePresentation(TenantReview $record): array
|
||||||
|
{
|
||||||
|
$summary = is_array($record->summary) ? $record->summary : [];
|
||||||
|
$package = is_array($summary['governance_package'] ?? null) ? $summary['governance_package'] : [];
|
||||||
|
|
||||||
|
if ($package === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($package, [
|
||||||
|
'availability' => static::governancePackageAvailability($record),
|
||||||
|
'delivery_note' => __('localization.review.governance_package_delivery_note'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{state:string,label:string,description:string}
|
||||||
|
*/
|
||||||
|
private static function governancePackageAvailability(TenantReview $record): array
|
||||||
|
{
|
||||||
|
$pack = $record->currentExportReviewPack;
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
$user = auth()->user();
|
||||||
|
$controlInterpretation = $record->controlInterpretation();
|
||||||
|
$limitations = is_array($controlInterpretation['limitations'] ?? null) ? $controlInterpretation['limitations'] : [];
|
||||||
|
$isPartialReview = in_array((string) $record->completeness_state, [
|
||||||
|
TenantReviewCompletenessState::Partial->value,
|
||||||
|
TenantReviewCompletenessState::Stale->value,
|
||||||
|
], true) || $limitations !== [];
|
||||||
|
|
||||||
|
if (! $pack instanceof ReviewPack) {
|
||||||
|
return [
|
||||||
|
'state' => 'unavailable',
|
||||||
|
'label' => __('localization.review.governance_package_unavailable'),
|
||||||
|
'description' => __('localization.review.governance_package_unavailable_description'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||||
|
return [
|
||||||
|
'state' => 'blocked',
|
||||||
|
'label' => __('localization.review.governance_package_blocked'),
|
||||||
|
'description' => __('localization.review.governance_package_blocked_description'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pack->status === ReviewPackStatus::Expired->value || ($pack->expires_at !== null && $pack->expires_at->isPast())) {
|
||||||
|
return [
|
||||||
|
'state' => 'expired',
|
||||||
|
'label' => __('localization.review.governance_package_expired'),
|
||||||
|
'description' => __('localization.review.governance_package_expired_description'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||||
|
return [
|
||||||
|
'state' => 'unavailable',
|
||||||
|
'label' => __('localization.review.governance_package_unavailable'),
|
||||||
|
'description' => __('localization.review.governance_package_not_ready_description'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isPartialReview) {
|
||||||
|
return [
|
||||||
|
'state' => 'partial',
|
||||||
|
'label' => __('localization.review.governance_package_partial'),
|
||||||
|
'description' => __('localization.review.governance_package_partial_description'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'state' => 'available',
|
||||||
|
'label' => __('localization.review.governance_package_available'),
|
||||||
|
'description' => __('localization.review.governance_package_available_description'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{title:string,label:string,url:?string,description:string}>
|
||||||
|
*/
|
||||||
|
private static function summaryContextLinks(TenantReview $record, bool $customerWorkspaceMode = false): array
|
||||||
{
|
{
|
||||||
$links = [];
|
$links = [];
|
||||||
|
|
||||||
if (is_numeric($record->operation_run_id)) {
|
if (! $customerWorkspaceMode && is_numeric($record->operation_run_id)) {
|
||||||
$links[] = [
|
$links[] = [
|
||||||
'title' => __('localization.review.operation'),
|
'title' => __('localization.review.operation'),
|
||||||
'label' => __('localization.review.open_operation'),
|
'label' => __('localization.review.open_operation'),
|
||||||
@ -679,7 +854,7 @@ private static function summaryContextLinks(TenantReview $record): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($record->currentExportReviewPack && $record->tenant) {
|
if (! $customerWorkspaceMode && $record->currentExportReviewPack && $record->tenant) {
|
||||||
$links[] = [
|
$links[] = [
|
||||||
'title' => __('localization.review.executive_pack'),
|
'title' => __('localization.review.executive_pack'),
|
||||||
'label' => __('localization.review.view_executive_pack'),
|
'label' => __('localization.review.view_executive_pack'),
|
||||||
@ -698,11 +873,23 @@ private static function summaryContextLinks(TenantReview $record): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($record->evidenceSnapshot && $record->tenant) {
|
if ($record->evidenceSnapshot && $record->tenant) {
|
||||||
|
$user = auth()->user();
|
||||||
|
$canViewEvidence = $user instanceof User && $user->can(Capabilities::EVIDENCE_VIEW, $record->tenant);
|
||||||
|
$evidenceUrl = $canViewEvidence
|
||||||
|
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($customerWorkspaceMode && $evidenceUrl !== null) {
|
||||||
|
$evidenceUrl = static::appendQuery($evidenceUrl, static::customerWorkspaceEvidenceQuery($record));
|
||||||
|
}
|
||||||
|
|
||||||
$links[] = [
|
$links[] = [
|
||||||
'title' => __('localization.review.evidence_snapshot'),
|
'title' => __('localization.review.evidence_snapshot'),
|
||||||
'label' => __('localization.review.view_evidence_snapshot'),
|
'label' => __('localization.review.view_evidence_snapshot'),
|
||||||
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
|
'url' => $evidenceUrl,
|
||||||
'description' => __('localization.review.evidence_snapshot_description'),
|
'description' => $canViewEvidence
|
||||||
|
? __('localization.review.evidence_snapshot_description')
|
||||||
|
: __('localization.review.evidence_proof_access_unavailable'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -718,6 +905,24 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
|||||||
$render = is_array($section->render_payload) ? $section->render_payload : [];
|
$render = is_array($section->render_payload) ? $section->render_payload : [];
|
||||||
$review = $section->tenantReview;
|
$review = $section->tenantReview;
|
||||||
$tenant = $section->tenant;
|
$tenant = $section->tenant;
|
||||||
|
$links = [];
|
||||||
|
|
||||||
|
if ($section->isControlInterpretation() && $review instanceof TenantReview && $tenant instanceof Tenant && $review->evidenceSnapshot instanceof EvidenceSnapshot) {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if ($user instanceof User && $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||||
|
$evidenceUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $tenant);
|
||||||
|
|
||||||
|
if (static::isCustomerWorkspaceMode()) {
|
||||||
|
$evidenceUrl = static::appendQuery($evidenceUrl, static::customerWorkspaceEvidenceQuery($review));
|
||||||
|
}
|
||||||
|
|
||||||
|
$links[] = [
|
||||||
|
'label' => __('localization.review.view_evidence_snapshot'),
|
||||||
|
'url' => $evidenceUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'summary' => collect($summary)->map(function (mixed $value, string $key): ?array {
|
'summary' => collect($summary)->map(function (mixed $value, string $key): ?array {
|
||||||
@ -735,7 +940,8 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
|||||||
'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null,
|
'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null,
|
||||||
'next_actions' => is_array($render['next_actions'] ?? null) ? $render['next_actions'] : [],
|
'next_actions' => is_array($render['next_actions'] ?? null) ? $render['next_actions'] : [],
|
||||||
'empty_state' => is_string($render['empty_state'] ?? null) ? $render['empty_state'] : null,
|
'empty_state' => is_string($render['empty_state'] ?? null) ? $render['empty_state'] : null,
|
||||||
'links' => [],
|
'is_control_interpretation' => $section->isControlInterpretation(),
|
||||||
|
'links' => $links,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -783,4 +989,34 @@ private static function findingOutcomeSummary(array $summary): ?string
|
|||||||
|
|
||||||
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
|
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function isCustomerWorkspaceMode(): bool
|
||||||
|
{
|
||||||
|
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function customerWorkspaceEvidenceQuery(TenantReview $record): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||||
|
'review_id' => (int) $record->getKey(),
|
||||||
|
'interpretation_version' => $record->controlInterpretationVersion(),
|
||||||
|
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $query
|
||||||
|
*/
|
||||||
|
private static function appendQuery(string $url, array $query): string
|
||||||
|
{
|
||||||
|
if ($query === []) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,15 +6,18 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
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\ReviewPackService;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
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\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\ReviewPackStatus;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\TenantReviewStatus;
|
||||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -64,6 +67,12 @@ protected function authorizeAccess(): void
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
|
if ($this->isCustomerWorkspaceView()) {
|
||||||
|
return [
|
||||||
|
$this->downloadCurrentReviewPackAction(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$secondaryActions = $this->secondaryLifecycleActions();
|
$secondaryActions = $this->secondaryLifecycleActions();
|
||||||
|
|
||||||
return array_values(array_filter([
|
return array_values(array_filter([
|
||||||
@ -343,6 +352,77 @@ private function archiveReviewAction(): Actions\Action
|
|||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function downloadCurrentReviewPackAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return Actions\Action::make('download_current_review_pack')
|
||||||
|
->label(__('localization.review.download_governance_package'))
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->color('primary')
|
||||||
|
->disabled(fn (): bool => $this->currentReviewPackDownloadUrl() === null)
|
||||||
|
->tooltip(fn (): ?string => $this->currentReviewPackUnavailableReason())
|
||||||
|
->url(fn (): ?string => $this->currentReviewPackDownloadUrl())
|
||||||
|
->openUrlInNewTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentReviewPackDownloadUrl(): ?string
|
||||||
|
{
|
||||||
|
$pack = $this->record->currentExportReviewPack;
|
||||||
|
$tenant = $this->record->tenant;
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $pack instanceof ReviewPack || ! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
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' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||||
|
'review_id' => (int) $this->record->getKey(),
|
||||||
|
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||||
|
'interpretation_version' => $this->record->controlInterpretationVersion(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentReviewPackUnavailableReason(): ?string
|
||||||
|
{
|
||||||
|
if ($this->currentReviewPackDownloadUrl() !== null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pack = $this->record->currentExportReviewPack;
|
||||||
|
$tenant = $this->record->tenant;
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $pack instanceof ReviewPack) {
|
||||||
|
return __('localization.review.customer_review_pack_missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||||
|
return __('localization.review.customer_review_pack_forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||||
|
return __('localization.review.customer_review_pack_not_ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||||
|
return __('localization.review.customer_review_pack_expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
return __('localization.review.customer_review_pack_unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
private function isCustomerWorkspaceView(): bool
|
private function isCustomerWorkspaceView(): bool
|
||||||
{
|
{
|
||||||
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
||||||
@ -367,7 +447,9 @@ private function auditCustomerWorkspaceOpen(): void
|
|||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'review_id' => (int) $this->record->getKey(),
|
'review_id' => (int) $this->record->getKey(),
|
||||||
'source_surface' => 'customer_review_workspace',
|
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||||
|
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||||
|
'interpretation_version' => $this->record->controlInterpretationVersion(),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
actor: $user,
|
actor: $user,
|
||||||
|
|||||||
@ -9,8 +9,10 @@
|
|||||||
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\Models\WorkspaceSubscription;
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||||
|
use App\Services\Entitlements\WorkspaceSubscriptionResolver;
|
||||||
use App\Services\Settings\SettingsWriter;
|
use App\Services\Settings\SettingsWriter;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
||||||
@ -19,10 +21,14 @@
|
|||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Filament\Schemas\Components\Utilities\Set;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class ViewWorkspace extends Page
|
class ViewWorkspace extends Page
|
||||||
@ -114,11 +120,123 @@ public function workspaceCommercialLifecycleSummary(): array
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
Action::make('update_subscription_truth')
|
||||||
|
->label('Update subscription truth')
|
||||||
|
->icon('heroicon-o-credit-card')
|
||||||
|
->visible(fn (): bool => $this->canManageCommercialLifecycle())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Update subscription truth')
|
||||||
|
->modalDescription('This records the current subscription-backed commercial truth for the workspace and becomes the upstream lifecycle source while the record exists.')
|
||||||
|
->form([
|
||||||
|
Select::make('state')
|
||||||
|
->label('Subscription state')
|
||||||
|
->options(WorkspaceSubscriptionResolver::stateLabels())
|
||||||
|
->required()
|
||||||
|
->live()
|
||||||
|
->afterStateUpdated(function (Set $set, ?string $state): void {
|
||||||
|
$normalizedState = $this->normalizeSubscriptionState($state);
|
||||||
|
|
||||||
|
if ($normalizedState !== WorkspaceSubscription::STATE_TRIAL) {
|
||||||
|
$set('trial_ends_at', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($normalizedState, [
|
||||||
|
WorkspaceSubscription::STATE_ACTIVE,
|
||||||
|
WorkspaceSubscription::STATE_PAST_DUE,
|
||||||
|
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||||
|
], true)) {
|
||||||
|
$set('current_period_starts_at', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($normalizedState, [
|
||||||
|
WorkspaceSubscription::STATE_ACTIVE,
|
||||||
|
WorkspaceSubscription::STATE_PAST_DUE,
|
||||||
|
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||||
|
WorkspaceSubscription::STATE_ENDED,
|
||||||
|
], true)) {
|
||||||
|
$set('current_period_ends_at', null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->helperText('Trial requires a trial end date. Active, past due, cancellation pending, and ended states require the matching current-period dates.')
|
||||||
|
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->state),
|
||||||
|
TextInput::make('billing_reference')
|
||||||
|
->label('Billing reference')
|
||||||
|
->maxLength(191)
|
||||||
|
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->billing_reference),
|
||||||
|
DateTimePicker::make('trial_ends_at')
|
||||||
|
->label('Trial ends at')
|
||||||
|
->required(fn (Get $get): bool => $this->selectedSubscriptionState($get) === WorkspaceSubscription::STATE_TRIAL)
|
||||||
|
->visible(fn (Get $get): bool => $this->selectedSubscriptionState($get) === WorkspaceSubscription::STATE_TRIAL)
|
||||||
|
->helperText('Required when the subscription state is Trial.')
|
||||||
|
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->trial_ends_at?->toDateTimeString()),
|
||||||
|
DateTimePicker::make('current_period_starts_at')
|
||||||
|
->label('Current period starts at')
|
||||||
|
->required(fn (Get $get): bool => in_array($this->selectedSubscriptionState($get), [
|
||||||
|
WorkspaceSubscription::STATE_ACTIVE,
|
||||||
|
WorkspaceSubscription::STATE_PAST_DUE,
|
||||||
|
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||||
|
], true))
|
||||||
|
->visible(fn (Get $get): bool => in_array($this->selectedSubscriptionState($get), [
|
||||||
|
WorkspaceSubscription::STATE_ACTIVE,
|
||||||
|
WorkspaceSubscription::STATE_PAST_DUE,
|
||||||
|
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||||
|
], true))
|
||||||
|
->helperText('Required for Active, Past due, and Cancellation pending subscriptions.')
|
||||||
|
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->current_period_starts_at?->toDateTimeString()),
|
||||||
|
DateTimePicker::make('current_period_ends_at')
|
||||||
|
->label('Current period ends at')
|
||||||
|
->required(fn (Get $get): bool => in_array($this->selectedSubscriptionState($get), [
|
||||||
|
WorkspaceSubscription::STATE_ACTIVE,
|
||||||
|
WorkspaceSubscription::STATE_PAST_DUE,
|
||||||
|
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||||
|
WorkspaceSubscription::STATE_ENDED,
|
||||||
|
], true))
|
||||||
|
->visible(fn (Get $get): bool => in_array($this->selectedSubscriptionState($get), [
|
||||||
|
WorkspaceSubscription::STATE_ACTIVE,
|
||||||
|
WorkspaceSubscription::STATE_PAST_DUE,
|
||||||
|
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||||
|
WorkspaceSubscription::STATE_ENDED,
|
||||||
|
], true))
|
||||||
|
->helperText('Required for Active, Past due, Cancellation pending, and Ended subscriptions.')
|
||||||
|
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->current_period_ends_at?->toDateTimeString()),
|
||||||
|
Textarea::make('status_reason')
|
||||||
|
->label('Status reason')
|
||||||
|
->required()
|
||||||
|
->minLength(5)
|
||||||
|
->maxLength(500)
|
||||||
|
->rows(4)
|
||||||
|
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->status_reason),
|
||||||
|
])
|
||||||
|
->action(function (array $data, SettingsWriter $settingsWriter): void {
|
||||||
|
$actor = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $actor instanceof PlatformUser) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$settingsWriter->updateWorkspaceSubscription(
|
||||||
|
actor: $actor,
|
||||||
|
workspace: $this->workspace,
|
||||||
|
attributes: $data,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->workspace = $this->workspace->fresh()->loadCount('tenants');
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Subscription truth updated')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
Action::make('change_commercial_state')
|
Action::make('change_commercial_state')
|
||||||
->label('Change commercial state')
|
->label('Change commercial state')
|
||||||
->icon('heroicon-o-adjustments-horizontal')
|
->icon('heroicon-o-adjustments-horizontal')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->visible(fn (): bool => $this->canManageCommercialLifecycle())
|
->visible(fn (): bool => $this->canManageCommercialLifecycle()
|
||||||
|
&& (bool) ($this->workspaceCommercialLifecycleSummary()['fallback_status'] ?? true))
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('Change commercial state')
|
->modalHeading('Change commercial state')
|
||||||
->modalDescription('This changes the workspace commercial lifecycle overlay. The rationale is audited and affects onboarding activation and review-pack starts.')
|
->modalDescription('This changes the workspace commercial lifecycle overlay. The rationale is audited and affects onboarding activation and review-pack starts.')
|
||||||
@ -171,6 +289,27 @@ private function canManageCommercialLifecycle(): bool
|
|||||||
&& $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE);
|
&& $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function currentWorkspaceSubscription(): ?WorkspaceSubscription
|
||||||
|
{
|
||||||
|
$this->workspace->loadMissing('subscription');
|
||||||
|
|
||||||
|
return $this->workspace->subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function selectedSubscriptionState(Get $get): string
|
||||||
|
{
|
||||||
|
return $this->normalizeSubscriptionState($get('state'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeSubscriptionState(mixed $state): string
|
||||||
|
{
|
||||||
|
$normalizedState = is_string($state) ? trim($state) : '';
|
||||||
|
|
||||||
|
return in_array($normalizedState, WorkspaceSubscription::stateIds(), true)
|
||||||
|
? $normalizedState
|
||||||
|
: WorkspaceSubscription::STATE_ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* overall: array{label: string, color: string, icon: string|null},
|
* overall: array{label: string, color: string, icon: string|null},
|
||||||
|
|||||||
@ -4,15 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
use App\Support\Rbac\UiTooltips;
|
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\StatsOverviewWidget;
|
use Filament\Widgets\StatsOverviewWidget;
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
@ -20,6 +14,7 @@
|
|||||||
class DashboardKpis extends StatsOverviewWidget
|
class DashboardKpis extends StatsOverviewWidget
|
||||||
{
|
{
|
||||||
protected int|string|array $columnSpan = 'full';
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
protected function getPollingInterval(): ?string
|
protected function getPollingInterval(): ?string
|
||||||
{
|
{
|
||||||
@ -34,129 +29,47 @@ protected function getStats(): array
|
|||||||
$tenant = Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return $this->emptyStats();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
$summary = app(TenantDashboardSummaryBuilder::class)->build($tenant, auth()->user());
|
||||||
|
|
||||||
|
$stats = [];
|
||||||
|
|
||||||
|
foreach ($summary->kpis as $kpi) {
|
||||||
|
if (count($stats) >= 4) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
$openDriftFindings = (int) Finding::query()
|
$color = $kpi['tone'] === 'gray' ? null : $kpi['tone'];
|
||||||
->where('tenant_id', $tenantId)
|
$icon = is_string($kpi['icon'] ?? null) ? $kpi['icon'] : null;
|
||||||
->openDrift()
|
$chart = is_array($kpi['chart'] ?? null) ? $kpi['chart'] : null;
|
||||||
->count();
|
|
||||||
|
|
||||||
$highSeverityActiveFindings = (int) Finding::query()
|
$stat = Stat::make($kpi['label'], (string) $kpi['value'])
|
||||||
->where('tenant_id', $tenantId)
|
->description($kpi['description'])
|
||||||
->highSeverityActive()
|
->color($color)
|
||||||
->count();
|
->extraAttributes([
|
||||||
|
'data-testid' => 'tenant-dashboard-kpi',
|
||||||
|
'data-kpi-key' => (string) ($kpi['key'] ?? ''),
|
||||||
|
'data-kpi-has-icon' => $icon !== null ? 'true' : 'false',
|
||||||
|
'data-kpi-has-chart' => $chart !== null ? 'true' : 'false',
|
||||||
|
]);
|
||||||
|
|
||||||
$activeRuns = (int) OperationRun::query()
|
if ($icon !== null) {
|
||||||
->where('tenant_id', $tenantId)
|
$stat->descriptionIcon($icon);
|
||||||
->healthyActive()
|
}
|
||||||
->count();
|
|
||||||
|
|
||||||
$staleActiveRuns = (int) OperationRun::query()
|
if ($chart !== null) {
|
||||||
->where('tenant_id', $tenantId)
|
$stat->chart(array_map(static fn (mixed $point): int => (int) $point, $chart));
|
||||||
->activeStaleAttention()
|
}
|
||||||
->count();
|
|
||||||
|
|
||||||
$terminalFollowUpRuns = (int) OperationRun::query()
|
if (!empty($kpi['actionUrl'])) {
|
||||||
->where('tenant_id', $tenantId)
|
$stat->url($kpi['actionUrl']);
|
||||||
->terminalFollowUp()
|
}
|
||||||
->count();
|
|
||||||
|
|
||||||
$openDriftUrl = $openDriftFindings > 0
|
$stats[] = $stat;
|
||||||
? $this->findingsUrl($tenant, [
|
|
||||||
'tab' => 'needs_action',
|
|
||||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
||||||
])
|
|
||||||
: null;
|
|
||||||
$highSeverityUrl = $highSeverityActiveFindings > 0
|
|
||||||
? $this->findingsUrl($tenant, [
|
|
||||||
'tab' => 'needs_action',
|
|
||||||
'high_severity' => 1,
|
|
||||||
])
|
|
||||||
: null;
|
|
||||||
$findingsHelperText = $this->findingsHelperText($tenant);
|
|
||||||
|
|
||||||
return [
|
|
||||||
Stat::make('Open drift findings', $openDriftFindings)
|
|
||||||
->description($openDriftUrl === null && $openDriftFindings > 0
|
|
||||||
? $findingsHelperText
|
|
||||||
: 'active drift workflow items')
|
|
||||||
->color($openDriftFindings > 0 ? 'warning' : 'gray')
|
|
||||||
->url($openDriftUrl),
|
|
||||||
Stat::make('High severity active findings', $highSeverityActiveFindings)
|
|
||||||
->description($highSeverityUrl === null && $highSeverityActiveFindings > 0
|
|
||||||
? $findingsHelperText
|
|
||||||
: 'high or critical findings needing review')
|
|
||||||
->color($highSeverityActiveFindings > 0 ? 'danger' : 'gray')
|
|
||||||
->url($highSeverityUrl),
|
|
||||||
Stat::make('Active operations', $activeRuns)
|
|
||||||
->description('healthy queued or running tenant work')
|
|
||||||
->color($activeRuns > 0 ? 'info' : 'gray')
|
|
||||||
->url($activeRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'active') : null),
|
|
||||||
Stat::make('Likely stale operations', $staleActiveRuns)
|
|
||||||
->description('queued or running past the lifecycle window')
|
|
||||||
->color($staleActiveRuns > 0 ? 'warning' : 'gray')
|
|
||||||
->url($staleActiveRuns > 0
|
|
||||||
? OperationRunLinks::index(
|
|
||||||
$tenant,
|
|
||||||
activeTab: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
|
||||||
problemClass: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
|
||||||
)
|
|
||||||
: null),
|
|
||||||
Stat::make('Terminal follow-up operations', $terminalFollowUpRuns)
|
|
||||||
->description('blocked, partial, failed, or auto-reconciled runs')
|
|
||||||
->color($terminalFollowUpRuns > 0 ? 'danger' : 'gray')
|
|
||||||
->url($terminalFollowUpRuns > 0
|
|
||||||
? OperationRunLinks::index(
|
|
||||||
$tenant,
|
|
||||||
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
|
||||||
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
|
||||||
)
|
|
||||||
: null),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Stat>
|
|
||||||
*/
|
|
||||||
private function emptyStats(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Stat::make('Open drift findings', 0),
|
|
||||||
Stat::make('High severity active findings', 0),
|
|
||||||
Stat::make('Active operations', 0),
|
|
||||||
Stat::make('Likely stale operations', 0),
|
|
||||||
Stat::make('Terminal follow-up operations', 0),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $parameters
|
|
||||||
*/
|
|
||||||
private function findingsUrl(Tenant $tenant, array $parameters): ?string
|
|
||||||
{
|
|
||||||
if (! $this->canOpenFindings($tenant)) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant);
|
return $stats;
|
||||||
}
|
|
||||||
|
|
||||||
private function findingsHelperText(Tenant $tenant): string
|
|
||||||
{
|
|
||||||
return $this->canOpenFindings($tenant)
|
|
||||||
? 'Open findings'
|
|
||||||
: UiTooltips::INSUFFICIENT_PERMISSION;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function canOpenFindings(Tenant $tenant): bool
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
return $user instanceof User
|
|
||||||
&& $user->canAccessTenant($tenant)
|
|
||||||
&& $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
|
class TenantDashboardContextChips extends Widget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
|
protected string $view = 'filament.widgets.dashboard.tenant-dashboard-context-chips';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
'context' => [
|
||||||
|
'workspace' => __('localization.dashboard.overview.context_workspace'),
|
||||||
|
'tenant' => __('localization.dashboard.overview.context_no_tenant'),
|
||||||
|
'provider' => null,
|
||||||
|
'providerKey' => null,
|
||||||
|
'latestActivity' => null,
|
||||||
|
],
|
||||||
|
'pollingInterval' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = app(TenantDashboardSummaryBuilder::class)->build($tenant, auth()->user());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'context' => $summary->context,
|
||||||
|
'pollingInterval' => $summary->pollingInterval,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\TenantDashboard\TenantDashboardSummaryBuilder;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
|
class TenantDashboardOverview extends Widget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
|
protected string $view = 'filament.widgets.dashboard.tenant-dashboard-overview';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
'context' => [
|
||||||
|
'workspace' => __('localization.dashboard.overview.context_workspace'),
|
||||||
|
'tenant' => __('localization.dashboard.overview.context_no_tenant'),
|
||||||
|
'provider' => null,
|
||||||
|
'providerKey' => null,
|
||||||
|
'latestActivity' => null,
|
||||||
|
],
|
||||||
|
'posture' => [
|
||||||
|
'status' => __('localization.dashboard.overview.status_unavailable'),
|
||||||
|
'tone' => 'gray',
|
||||||
|
'headline' => __('localization.dashboard.overview.tenant_context_unavailable_headline'),
|
||||||
|
'summary' => __('localization.dashboard.overview.tenant_context_unavailable_summary'),
|
||||||
|
],
|
||||||
|
'kpis' => [],
|
||||||
|
'recommendedActions' => [],
|
||||||
|
'governanceStatus' => [],
|
||||||
|
'readinessCards' => [],
|
||||||
|
'recentOperations' => [],
|
||||||
|
'pollingInterval' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(TenantDashboardSummaryBuilder::class)
|
||||||
|
->build($tenant)
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,10 +17,13 @@
|
|||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use Filament\Widgets\StatsOverviewWidget;
|
use Filament\Widgets\StatsOverviewWidget;
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\HtmlString;
|
use Illuminate\Support\HtmlString;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
|
||||||
class InventoryKpiHeader extends StatsOverviewWidget
|
class InventoryKpiHeader extends StatsOverviewWidget
|
||||||
{
|
{
|
||||||
@ -30,7 +33,26 @@ class InventoryKpiHeader extends StatsOverviewWidget
|
|||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
protected ?string $pollingInterval = null;
|
protected function getPollingInterval(): ?string
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActiveRuns::countForTenantId((int) $tenant->getKey()) > 0 ? '10s' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[On(OpsUxBrowserEvents::RunEnqueued)]
|
||||||
|
public function onRunEnqueued(?int $tenantId = null): void
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
|
if ($tenantId !== null && $tenant instanceof Tenant && (int) $tenant->getKey() !== (int) $tenantId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<Stat>
|
* @return array<Stat>
|
||||||
@ -51,15 +73,10 @@ protected function getStats(): array
|
|||||||
|
|
||||||
$truth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
|
$truth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
|
||||||
|
|
||||||
$activeOps = (int) OperationRun::query()
|
$activeOps = ActiveRuns::countForTenantId((int) $tenant->getKey());
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$inventoryOps = (int) OperationRun::query()
|
$inventoryOps = (int) ActiveRuns::queryForTenantId((int) $tenant->getKey())
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value))
|
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value))
|
||||||
->active()
|
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -59,6 +59,9 @@ public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResp
|
|||||||
? (int) $reviewPack->tenant_review_id
|
? (int) $reviewPack->tenant_review_id
|
||||||
: null,
|
: null,
|
||||||
'source_surface' => (string) $request->query('source_surface', 'review_pack'),
|
'source_surface' => (string) $request->query('source_surface', 'review_pack'),
|
||||||
|
'review_id' => $request->query('review_id'),
|
||||||
|
'tenant_filter_id' => $request->query('tenant_filter_id'),
|
||||||
|
'interpretation_version' => $request->query('interpretation_version'),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
actor: $user,
|
actor: $user,
|
||||||
|
|||||||
@ -132,11 +132,26 @@ public function handle(
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$operationRunService->updateRun($this->operationRun, 'running', 'pending');
|
$existingCounts = is_array($this->operationRun->summary_counts ?? null)
|
||||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
? $this->operationRun->summary_counts
|
||||||
'total' => count($policyIds),
|
: [];
|
||||||
'items' => count($policyIds),
|
$basePolicyCount = count($policyIds);
|
||||||
]);
|
|
||||||
|
$this->operationRun = $operationRunService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: 'running',
|
||||||
|
outcome: 'pending',
|
||||||
|
summaryCounts: array_merge($existingCounts, [
|
||||||
|
'total' => max((int) ($existingCounts['total'] ?? 0), $basePolicyCount),
|
||||||
|
'items' => max((int) ($existingCounts['items'] ?? 0), $basePolicyCount),
|
||||||
|
'processed' => (int) ($existingCounts['processed'] ?? 0),
|
||||||
|
'succeeded' => (int) ($existingCounts['succeeded'] ?? 0),
|
||||||
|
'failed' => (int) ($existingCounts['failed'] ?? 0),
|
||||||
|
'skipped' => (int) ($existingCounts['skipped'] ?? 0),
|
||||||
|
'created' => (int) ($existingCounts['created'] ?? 0),
|
||||||
|
'updated' => (int) ($existingCounts['updated'] ?? 0),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
if ($policyIds === []) {
|
if ($policyIds === []) {
|
||||||
$operationRunService->updateRun(
|
$operationRunService->updateRun(
|
||||||
@ -242,12 +257,33 @@ public function handle(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($policy->ignored_at) {
|
if (! $policy->isCurrentBackupEligible()) {
|
||||||
|
$reasonCode = match ($policy->currentBackupBlockedReason()) {
|
||||||
|
Policy::VISIBILITY_PROVIDER_MISSING => 'policy_provider_missing',
|
||||||
|
Policy::VISIBILITY_IGNORED_LOCALLY => 'policy_ignored_locally',
|
||||||
|
default => 'policy_not_current_backup_eligible',
|
||||||
|
};
|
||||||
|
$reason = $policy->currentBackupBlockedReasonLabel()
|
||||||
|
?? 'Policy is not eligible for current backup capture.';
|
||||||
|
|
||||||
|
$newBackupFailures[] = [
|
||||||
|
'policy_id' => $policyId,
|
||||||
|
'reason' => RunFailureSanitizer::sanitizeMessage($reason),
|
||||||
|
'status' => null,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
];
|
||||||
|
$didMutateBackupSet = true;
|
||||||
|
|
||||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||||
'processed' => 1,
|
'processed' => 1,
|
||||||
'skipped' => 1,
|
'failed' => 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$runFailuresForOperationRun[] = [
|
||||||
|
'code' => str_replace('_', '.', $reasonCode),
|
||||||
|
'message' => RunFailureSanitizer::sanitizeMessage($reason),
|
||||||
|
];
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -57,8 +57,21 @@ public function handle(OperationRunService $runs): void
|
|||||||
$runs->updateRun($this->operationRun, 'running');
|
$runs->updateRun($this->operationRun, 'running');
|
||||||
|
|
||||||
$ids = $this->normalizeIds($this->backupSetIds);
|
$ids = $this->normalizeIds($this->backupSetIds);
|
||||||
|
$existingCounts = is_array($this->operationRun->summary_counts ?? null)
|
||||||
|
? $this->operationRun->summary_counts
|
||||||
|
: [];
|
||||||
|
|
||||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
$this->operationRun = $runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: 'running',
|
||||||
|
summaryCounts: [
|
||||||
|
'total' => max((int) ($existingCounts['total'] ?? 0), count($ids)),
|
||||||
|
'processed' => (int) ($existingCounts['processed'] ?? 0),
|
||||||
|
'succeeded' => (int) ($existingCounts['succeeded'] ?? 0),
|
||||||
|
'failed' => (int) ($existingCounts['failed'] ?? 0),
|
||||||
|
'skipped' => (int) ($existingCounts['skipped'] ?? 0),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||||
$chunkSize = max(1, $chunkSize);
|
$chunkSize = max(1, $chunkSize);
|
||||||
|
|||||||
@ -120,6 +120,49 @@ public function handle(OperationRunService $operationRunService): void
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $policy->isCurrentBackupEligible()) {
|
||||||
|
$failed++;
|
||||||
|
$reasonCode = match ($policy->currentBackupBlockedReason()) {
|
||||||
|
Policy::VISIBILITY_PROVIDER_MISSING => 'policy.provider_missing',
|
||||||
|
Policy::VISIBILITY_IGNORED_LOCALLY => 'policy.ignored_locally',
|
||||||
|
default => 'policy.not_current_backup_eligible',
|
||||||
|
};
|
||||||
|
$failures[] = [
|
||||||
|
'code' => $reasonCode,
|
||||||
|
'message' => $policy->currentBackupBlockedReasonLabel()
|
||||||
|
?? "Policy {$policyId} is not eligible for current backup capture.",
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($failed > $failureThreshold) {
|
||||||
|
$backupSet->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'item_count' => $succeeded,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($this->operationRun) {
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
summaryCounts: [
|
||||||
|
'total' => $totalItems,
|
||||||
|
'processed' => $itemCount,
|
||||||
|
'succeeded' => $succeeded,
|
||||||
|
'failed' => $failed,
|
||||||
|
'created' => $succeeded,
|
||||||
|
],
|
||||||
|
failures: array_merge($failures, [
|
||||||
|
['code' => 'export.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Get latest version for snapshot
|
// Get latest version for snapshot
|
||||||
$latestVersion = $policy->versions()->orderByDesc('captured_at')->first();
|
$latestVersion = $policy->versions()->orderByDesc('captured_at')->first();
|
||||||
|
|
||||||
@ -215,6 +258,15 @@ public function handle(OperationRunService $operationRunService): void
|
|||||||
$outcome = OperationRunOutcome::Failed->value;
|
$outcome = OperationRunOutcome::Failed->value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$backupSet->update([
|
||||||
|
'status' => match ($outcome) {
|
||||||
|
OperationRunOutcome::Failed->value => 'failed',
|
||||||
|
OperationRunOutcome::PartiallySucceeded->value => 'partial',
|
||||||
|
default => 'completed',
|
||||||
|
},
|
||||||
|
'item_count' => $succeeded,
|
||||||
|
]);
|
||||||
|
|
||||||
if ($this->operationRun) {
|
if ($this->operationRun) {
|
||||||
$operationRunService->updateRun(
|
$operationRunService->updateRun(
|
||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
|
|||||||
@ -161,6 +161,15 @@ public function handle(
|
|||||||
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
|
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
$context['progress'] = array_merge(
|
||||||
|
is_array($context['progress'] ?? null) ? $context['progress'] : [],
|
||||||
|
[
|
||||||
|
'phase' => [
|
||||||
|
'key' => 'preparing',
|
||||||
|
'label' => 'Preparing baseline capture.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
$this->operationRun->update(['context' => $context]);
|
$this->operationRun->update(['context' => $context]);
|
||||||
$this->operationRun->refresh();
|
$this->operationRun->refresh();
|
||||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
@ -284,6 +293,19 @@ public function handle(
|
|||||||
$resumeToken = null;
|
$resumeToken = null;
|
||||||
|
|
||||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||||
|
$processingContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
|
$processingContext['progress'] = array_merge(
|
||||||
|
is_array($processingContext['progress'] ?? null) ? $processingContext['progress'] : [],
|
||||||
|
[
|
||||||
|
'phase' => [
|
||||||
|
'key' => 'processing',
|
||||||
|
'label' => 'Capturing evidence.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$this->operationRun->update(['context' => $processingContext]);
|
||||||
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
$budgets = [
|
$budgets = [
|
||||||
'max_items_per_run' => (int) config('tenantpilot.baselines.full_content_capture.max_items_per_run', 200),
|
'max_items_per_run' => (int) config('tenantpilot.baselines.full_content_capture.max_items_per_run', 200),
|
||||||
'max_concurrency' => (int) config('tenantpilot.baselines.full_content_capture.max_concurrency', 5),
|
'max_concurrency' => (int) config('tenantpilot.baselines.full_content_capture.max_concurrency', 5),
|
||||||
@ -348,6 +370,19 @@ public function handle(
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$persistingContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
|
$persistingContext['progress'] = array_merge(
|
||||||
|
is_array($persistingContext['progress'] ?? null) ? $persistingContext['progress'] : [],
|
||||||
|
[
|
||||||
|
'phase' => [
|
||||||
|
'key' => 'persisting',
|
||||||
|
'label' => 'Saving baseline snapshot.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$this->operationRun->update(['context' => $persistingContext]);
|
||||||
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
if ($subjectsTotal === 0) {
|
if ($subjectsTotal === 0) {
|
||||||
$snapshotResult = $this->captureNoDataSnapshotArtifact(
|
$snapshotResult = $this->captureNoDataSnapshotArtifact(
|
||||||
$profile,
|
$profile,
|
||||||
@ -395,6 +430,15 @@ public function handle(
|
|||||||
'current_baseline_changed' => false,
|
'current_baseline_changed' => false,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
$updatedContext['progress'] = array_merge(
|
||||||
|
is_array($updatedContext['progress'] ?? null) ? $updatedContext['progress'] : [],
|
||||||
|
[
|
||||||
|
'phase' => [
|
||||||
|
'key' => 'finalizing',
|
||||||
|
'label' => 'Finalizing baseline capture.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
$this->operationRun->update([
|
$this->operationRun->update([
|
||||||
'context' => $updatedContext,
|
'context' => $updatedContext,
|
||||||
@ -496,6 +540,15 @@ public function handle(
|
|||||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
||||||
'current_baseline_changed' => $currentBaselineChanged,
|
'current_baseline_changed' => $currentBaselineChanged,
|
||||||
];
|
];
|
||||||
|
$updatedContext['progress'] = array_merge(
|
||||||
|
is_array($updatedContext['progress'] ?? null) ? $updatedContext['progress'] : [],
|
||||||
|
[
|
||||||
|
'phase' => [
|
||||||
|
'key' => 'finalizing',
|
||||||
|
'label' => 'Finalizing baseline capture.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
$this->auditCompleted(
|
$this->auditCompleted(
|
||||||
|
|||||||
@ -338,6 +338,15 @@ public function handle(
|
|||||||
|
|
||||||
$strategySelection = $compareStrategyRegistry->select($effectiveScope);
|
$strategySelection = $compareStrategyRegistry->select($effectiveScope);
|
||||||
$context = $this->withCompareStrategySelection($context, $strategySelection);
|
$context = $this->withCompareStrategySelection($context, $strategySelection);
|
||||||
|
$context['progress'] = array_merge(
|
||||||
|
is_array($context['progress'] ?? null) ? $context['progress'] : [],
|
||||||
|
[
|
||||||
|
'phase' => [
|
||||||
|
'key' => 'preparing',
|
||||||
|
'label' => 'Preparing baseline comparison.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
$this->operationRun->update(['context' => $context]);
|
$this->operationRun->update(['context' => $context]);
|
||||||
$this->operationRun->refresh();
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
@ -429,6 +438,19 @@ public function handle(
|
|||||||
$resumeToken = null;
|
$resumeToken = null;
|
||||||
|
|
||||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||||
|
$processingContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
|
$processingContext['progress'] = array_merge(
|
||||||
|
is_array($processingContext['progress'] ?? null) ? $processingContext['progress'] : [],
|
||||||
|
[
|
||||||
|
'phase' => [
|
||||||
|
'key' => 'processing',
|
||||||
|
'label' => 'Refreshing comparison evidence.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$this->operationRun->update(['context' => $processingContext]);
|
||||||
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
$budgets = [
|
$budgets = [
|
||||||
'max_items_per_run' => (int) config('tenantpilot.baselines.full_content_capture.max_items_per_run', 200),
|
'max_items_per_run' => (int) config('tenantpilot.baselines.full_content_capture.max_items_per_run', 200),
|
||||||
'max_concurrency' => (int) config('tenantpilot.baselines.full_content_capture.max_concurrency', 5),
|
'max_concurrency' => (int) config('tenantpilot.baselines.full_content_capture.max_concurrency', 5),
|
||||||
@ -512,6 +534,19 @@ public function handle(
|
|||||||
launchContext: is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
launchContext: is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$comparePhaseContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
|
$comparePhaseContext['progress'] = array_merge(
|
||||||
|
is_array($comparePhaseContext['progress'] ?? null) ? $comparePhaseContext['progress'] : [],
|
||||||
|
[
|
||||||
|
'phase' => [
|
||||||
|
'key' => 'processing',
|
||||||
|
'label' => 'Evaluating baseline drift.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$this->operationRun->update(['context' => $comparePhaseContext]);
|
||||||
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$compareResult = $strategy->compare(
|
$compareResult = $strategy->compare(
|
||||||
context: $orchestrationContext,
|
context: $orchestrationContext,
|
||||||
@ -694,6 +729,15 @@ public function handle(
|
|||||||
'counts_by_change_type' => $countsByChangeType,
|
'counts_by_change_type' => $countsByChangeType,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
$updatedContext['progress'] = array_merge(
|
||||||
|
is_array($updatedContext['progress'] ?? null) ? $updatedContext['progress'] : [],
|
||||||
|
[
|
||||||
|
'phase' => [
|
||||||
|
'key' => 'finalizing',
|
||||||
|
'label' => 'Finalizing baseline comparison.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
$updatedContext['result'] = [
|
$updatedContext['result'] = [
|
||||||
'findings_total' => count($driftResults),
|
'findings_total' => count($driftResults),
|
||||||
'findings_upserted' => (int) $upsertResult['processed_count'],
|
'findings_upserted' => (int) $upsertResult['processed_count'],
|
||||||
@ -944,6 +988,15 @@ private function completeWithCoverageWarning(
|
|||||||
'counts_by_change_type' => [],
|
'counts_by_change_type' => [],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
$updatedContext['progress'] = array_merge(
|
||||||
|
is_array($updatedContext['progress'] ?? null) ? $updatedContext['progress'] : [],
|
||||||
|
[
|
||||||
|
'phase' => [
|
||||||
|
'key' => 'finalizing',
|
||||||
|
'label' => 'Finalizing baseline comparison.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
$updatedContext['result'] = [
|
$updatedContext['result'] = [
|
||||||
'findings_total' => 0,
|
'findings_total' => 0,
|
||||||
'findings_upserted' => 0,
|
'findings_upserted' => 0,
|
||||||
|
|||||||
@ -42,6 +42,21 @@ public function handle(EvidenceSnapshotService $service, OperationRunService $op
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$payload = $service->buildSnapshotPayload($snapshot->tenant);
|
$payload = $service->buildSnapshotPayload($snapshot->tenant);
|
||||||
|
$itemCount = count($payload['items']);
|
||||||
|
|
||||||
|
$operationRun = $operationRuns->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: OperationRunStatus::Running->value,
|
||||||
|
outcome: OperationRunOutcome::Pending->value,
|
||||||
|
summaryCounts: [
|
||||||
|
'total' => $itemCount,
|
||||||
|
'processed' => 0,
|
||||||
|
'succeeded' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
$previousActive = EvidenceSnapshot::query()
|
$previousActive = EvidenceSnapshot::query()
|
||||||
->where('tenant_id', (int) $snapshot->tenant_id)
|
->where('tenant_id', (int) $snapshot->tenant_id)
|
||||||
->where('workspace_id', (int) $snapshot->workspace_id)
|
->where('workspace_id', (int) $snapshot->workspace_id)
|
||||||
@ -67,6 +82,11 @@ public function handle(EvidenceSnapshotService $service, OperationRunService $op
|
|||||||
'summary_payload' => $item['summary_payload'],
|
'summary_payload' => $item['summary_payload'],
|
||||||
'sort_order' => $item['sort_order'],
|
'sort_order' => $item['sort_order'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$operationRun = $operationRuns->incrementSummaryCounts($operationRun, [
|
||||||
|
'processed' => 1,
|
||||||
|
'created' => 1,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($previousActive instanceof EvidenceSnapshot && $previousActive->fingerprint !== $payload['fingerprint']) {
|
if ($previousActive instanceof EvidenceSnapshot && $previousActive->fingerprint !== $payload['fingerprint']) {
|
||||||
@ -89,7 +109,9 @@ public function handle(EvidenceSnapshotService $service, OperationRunService $op
|
|||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
summaryCounts: [
|
summaryCounts: [
|
||||||
'created' => 1,
|
'total' => $itemCount,
|
||||||
|
'processed' => $itemCount,
|
||||||
|
'created' => $itemCount,
|
||||||
'finding_count' => (int) ($payload['summary']['finding_count'] ?? 0),
|
'finding_count' => (int) ($payload['summary']['finding_count'] ?? 0),
|
||||||
'report_count' => (int) ($payload['summary']['report_count'] ?? 0),
|
'report_count' => (int) ($payload['summary']['report_count'] ?? 0),
|
||||||
'operation_count' => (int) ($payload['summary']['operation_count'] ?? 0),
|
'operation_count' => (int) ($payload['summary']['operation_count'] ?? 0),
|
||||||
|
|||||||
@ -121,12 +121,31 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
|||||||
includePii: $includePii,
|
includePii: $includePii,
|
||||||
includeOperations: $includeOperations,
|
includeOperations: $includeOperations,
|
||||||
);
|
);
|
||||||
|
$fileCount = count($fileMap);
|
||||||
|
|
||||||
|
$operationRun = $operationRunService->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: OperationRunStatus::Running->value,
|
||||||
|
outcome: OperationRunOutcome::Pending->value,
|
||||||
|
summaryCounts: [
|
||||||
|
'total' => $fileCount,
|
||||||
|
'processed' => 0,
|
||||||
|
'succeeded' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// 7. Assemble ZIP
|
// 7. Assemble ZIP
|
||||||
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->assembleZip($tempFile, $fileMap);
|
$this->assembleZip($tempFile, $fileMap, function () use ($operationRunService, &$operationRun): void {
|
||||||
|
$operationRun = $operationRunService->incrementSummaryCounts($operationRun, [
|
||||||
|
'processed' => 1,
|
||||||
|
'created' => 1,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
// 8. Compute SHA-256
|
// 8. Compute SHA-256
|
||||||
$sha256 = hash_file('sha256', $tempFile);
|
$sha256 = hash_file('sha256', $tempFile);
|
||||||
@ -184,7 +203,14 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
|||||||
$operationRun,
|
$operationRun,
|
||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
summaryCounts: $summary,
|
summaryCounts: [
|
||||||
|
'total' => $fileCount,
|
||||||
|
'processed' => $fileCount,
|
||||||
|
'created' => $fileCount,
|
||||||
|
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
||||||
|
'report_count' => (int) ($summary['report_count'] ?? 0),
|
||||||
|
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,19 +225,41 @@ private function executeReviewDerivedGeneration(
|
|||||||
$options = $reviewPack->options ?? [];
|
$options = $reviewPack->options ?? [];
|
||||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
$includePii = (bool) ($options['include_pii'] ?? true);
|
||||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
||||||
|
$generatedAt = now();
|
||||||
|
|
||||||
$fileMap = $this->buildReviewDerivedFileMap(
|
$fileMap = $this->buildReviewDerivedFileMap(
|
||||||
|
reviewPack: $reviewPack,
|
||||||
review: $review,
|
review: $review,
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
snapshot: $snapshot,
|
snapshot: $snapshot,
|
||||||
includePii: $includePii,
|
includePii: $includePii,
|
||||||
includeOperations: $includeOperations,
|
includeOperations: $includeOperations,
|
||||||
|
generatedAt: $generatedAt,
|
||||||
|
);
|
||||||
|
$fileCount = count($fileMap);
|
||||||
|
|
||||||
|
$operationRun = $operationRunService->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: OperationRunStatus::Running->value,
|
||||||
|
outcome: OperationRunOutcome::Pending->value,
|
||||||
|
summaryCounts: [
|
||||||
|
'total' => $fileCount,
|
||||||
|
'processed' => 0,
|
||||||
|
'succeeded' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->assembleZip($tempFile, $fileMap);
|
$this->assembleZip($tempFile, $fileMap, function () use ($operationRunService, &$operationRun): void {
|
||||||
|
$operationRun = $operationRunService->incrementSummaryCounts($operationRun, [
|
||||||
|
'processed' => 1,
|
||||||
|
'created' => 1,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
$sha256 = hash_file('sha256', $tempFile);
|
$sha256 = hash_file('sha256', $tempFile);
|
||||||
$fileSize = filesize($tempFile);
|
$fileSize = filesize($tempFile);
|
||||||
@ -219,7 +267,7 @@ private function executeReviewDerivedGeneration(
|
|||||||
'review-packs/%s/review-%d-%s.zip',
|
'review-packs/%s/review-%d-%s.zip',
|
||||||
$tenant->external_id,
|
$tenant->external_id,
|
||||||
(int) $review->getKey(),
|
(int) $review->getKey(),
|
||||||
now()->format('Y-m-d-His'),
|
$generatedAt->format('Y-m-d-His'),
|
||||||
);
|
);
|
||||||
|
|
||||||
Storage::disk('exports')->put($filePath, file_get_contents($tempFile));
|
Storage::disk('exports')->put($filePath, file_get_contents($tempFile));
|
||||||
@ -241,6 +289,7 @@ private function executeReviewDerivedGeneration(
|
|||||||
'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0,
|
'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0,
|
||||||
'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [],
|
'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [],
|
||||||
'publish_blockers' => is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [],
|
'publish_blockers' => is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [],
|
||||||
|
'delivery_bundle' => $this->deliveryBundleSummary($review),
|
||||||
'evidence_resolution' => [
|
'evidence_resolution' => [
|
||||||
'outcome' => 'resolved',
|
'outcome' => 'resolved',
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
@ -258,8 +307,8 @@ private function executeReviewDerivedGeneration(
|
|||||||
'file_size' => $fileSize,
|
'file_size' => $fileSize,
|
||||||
'file_path' => $filePath,
|
'file_path' => $filePath,
|
||||||
'file_disk' => 'exports',
|
'file_disk' => 'exports',
|
||||||
'generated_at' => now(),
|
'generated_at' => $generatedAt,
|
||||||
'expires_at' => now()->addDays($retentionDays),
|
'expires_at' => $generatedAt->copy()->addDays($retentionDays),
|
||||||
'summary' => $summary,
|
'summary' => $summary,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -276,7 +325,9 @@ private function executeReviewDerivedGeneration(
|
|||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
summaryCounts: [
|
summaryCounts: [
|
||||||
'created' => 1,
|
'total' => $fileCount,
|
||||||
|
'processed' => $fileCount,
|
||||||
|
'created' => $fileCount,
|
||||||
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
||||||
'report_count' => (int) ($summary['report_count'] ?? 0),
|
'report_count' => (int) ($summary['report_count'] ?? 0),
|
||||||
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
||||||
@ -559,7 +610,7 @@ private function classifier(): SecretClassificationService
|
|||||||
*
|
*
|
||||||
* @param array<string, string> $fileMap
|
* @param array<string, string> $fileMap
|
||||||
*/
|
*/
|
||||||
private function assembleZip(string $tempFile, array $fileMap): void
|
private function assembleZip(string $tempFile, array $fileMap, ?callable $afterWrite = null): void
|
||||||
{
|
{
|
||||||
$zip = new ZipArchive;
|
$zip = new ZipArchive;
|
||||||
$result = $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
$result = $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||||
@ -573,6 +624,8 @@ private function assembleZip(string $tempFile, array $fileMap): void
|
|||||||
|
|
||||||
foreach ($fileMap as $filename => $content) {
|
foreach ($fileMap as $filename => $content) {
|
||||||
$zip->addFromString($filename, $content);
|
$zip->addFromString($filename, $content);
|
||||||
|
|
||||||
|
$afterWrite && $afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
$zip->close();
|
$zip->close();
|
||||||
@ -582,13 +635,21 @@ private function assembleZip(string $tempFile, array $fileMap): void
|
|||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
private function buildReviewDerivedFileMap(
|
private function buildReviewDerivedFileMap(
|
||||||
|
ReviewPack $reviewPack,
|
||||||
TenantReview $review,
|
TenantReview $review,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
EvidenceSnapshot $snapshot,
|
EvidenceSnapshot $snapshot,
|
||||||
bool $includePii,
|
bool $includePii,
|
||||||
bool $includeOperations,
|
bool $includeOperations,
|
||||||
|
\Carbon\CarbonInterface $generatedAt,
|
||||||
): array {
|
): array {
|
||||||
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
||||||
|
$deliveryMetadata = $this->deliveryBundleMetadata(
|
||||||
|
reviewPack: $reviewPack,
|
||||||
|
review: $review,
|
||||||
|
snapshot: $snapshot,
|
||||||
|
generatedAt: $generatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
$sections = $review->sections
|
$sections = $review->sections
|
||||||
->filter(fn (mixed $section): bool => $includeOperations || $section->section_key !== 'operations_health')
|
->filter(fn (mixed $section): bool => $includeOperations || $section->section_key !== 'operations_health')
|
||||||
@ -599,7 +660,8 @@ private function buildReviewDerivedFileMap(
|
|||||||
'version' => '1.0',
|
'version' => '1.0',
|
||||||
'tenant_id' => $tenant->external_id,
|
'tenant_id' => $tenant->external_id,
|
||||||
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
||||||
'generated_at' => now()->toIso8601String(),
|
'generated_at' => $generatedAt->toIso8601String(),
|
||||||
|
'delivery_bundle' => $deliveryMetadata,
|
||||||
'tenant_review' => [
|
'tenant_review' => [
|
||||||
'id' => (int) $review->getKey(),
|
'id' => (int) $review->getKey(),
|
||||||
'status' => (string) $review->status,
|
'status' => (string) $review->status,
|
||||||
@ -622,11 +684,17 @@ private function buildReviewDerivedFileMap(
|
|||||||
'note' => RedactionIntegrity::protectedValueNote(),
|
'note' => RedactionIntegrity::protectedValueNote(),
|
||||||
],
|
],
|
||||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||||
'summary.json' => json_encode($this->redactReportPayload(array_merge([
|
'summary.json' => json_encode($this->redactReportPayload(array_merge(
|
||||||
'tenant_review_id' => (int) $review->getKey(),
|
[
|
||||||
'review_status' => (string) $review->status,
|
'tenant_review_id' => (int) $review->getKey(),
|
||||||
'review_completeness_state' => (string) $review->completeness_state,
|
'review_status' => (string) $review->status,
|
||||||
], $reviewSummary), $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
'review_completeness_state' => (string) $review->completeness_state,
|
||||||
|
],
|
||||||
|
$reviewSummary,
|
||||||
|
[
|
||||||
|
'delivery_bundle' => $this->deliveryBundleSummary($review),
|
||||||
|
],
|
||||||
|
), $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||||
'sections.json' => json_encode($sections->map(function ($section) use ($includePii): array {
|
'sections.json' => json_encode($sections->map(function ($section) use ($includePii): array {
|
||||||
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
|
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
|
||||||
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
|
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
|
||||||
@ -641,6 +709,14 @@ private function buildReviewDerivedFileMap(
|
|||||||
'render_payload' => $this->redactReportPayload($renderPayload, $includePii),
|
'render_payload' => $this->redactReportPayload($renderPayload, $includePii),
|
||||||
];
|
];
|
||||||
})->all(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
})->all(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||||
|
ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME => $this->buildExecutiveEntrypoint(
|
||||||
|
review: $review,
|
||||||
|
tenant: $tenant,
|
||||||
|
snapshot: $snapshot,
|
||||||
|
reviewSummary: $reviewSummary,
|
||||||
|
includePii: $includePii,
|
||||||
|
generatedAt: $generatedAt,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($sections as $section) {
|
foreach ($sections as $section) {
|
||||||
@ -659,6 +735,195 @@ private function buildReviewDerivedFileMap(
|
|||||||
return $files;
|
return $files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function deliveryBundleSummary(TenantReview $review): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'contract' => ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
||||||
|
'executive_entrypoint_file' => ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME,
|
||||||
|
'appendix_files' => ['metadata.json', 'summary.json', 'sections.json'],
|
||||||
|
'interpretation_version' => $review->controlInterpretationVersion(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function deliveryBundleMetadata(
|
||||||
|
ReviewPack $reviewPack,
|
||||||
|
TenantReview $review,
|
||||||
|
EvidenceSnapshot $snapshot,
|
||||||
|
\Carbon\CarbonInterface $generatedAt,
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'contract' => ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
||||||
|
'artifact_family' => 'review_pack',
|
||||||
|
'review_pack_id' => (int) $reviewPack->getKey(),
|
||||||
|
'generated_at' => $generatedAt->toIso8601String(),
|
||||||
|
'released_review' => [
|
||||||
|
'id' => (int) $review->getKey(),
|
||||||
|
'status' => (string) $review->status,
|
||||||
|
'completeness_state' => (string) $review->completeness_state,
|
||||||
|
'published_at' => $review->published_at?->toIso8601String(),
|
||||||
|
],
|
||||||
|
'interpretation_version' => $review->controlInterpretationVersion(),
|
||||||
|
'evidence_basis' => [
|
||||||
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||||
|
'completeness_state' => (string) $snapshot->completeness_state,
|
||||||
|
'generated_at' => $snapshot->generated_at?->toIso8601String(),
|
||||||
|
],
|
||||||
|
'entrypoint' => [
|
||||||
|
'file' => ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME,
|
||||||
|
'role' => 'executive_entrypoint',
|
||||||
|
'audience' => 'executive',
|
||||||
|
'format' => 'text/markdown',
|
||||||
|
],
|
||||||
|
'appendix' => [
|
||||||
|
[
|
||||||
|
'file' => 'metadata.json',
|
||||||
|
'role' => 'bundle_metadata',
|
||||||
|
'description' => 'Structured delivery metadata and artifact role map.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'file' => 'summary.json',
|
||||||
|
'role' => 'review_summary_appendix',
|
||||||
|
'description' => 'Structured released-review summary truth.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'file' => 'sections.json',
|
||||||
|
'role' => 'section_detail_appendix',
|
||||||
|
'description' => 'Structured released-review section detail.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $reviewSummary
|
||||||
|
*/
|
||||||
|
private function buildExecutiveEntrypoint(
|
||||||
|
TenantReview $review,
|
||||||
|
Tenant $tenant,
|
||||||
|
EvidenceSnapshot $snapshot,
|
||||||
|
array $reviewSummary,
|
||||||
|
bool $includePii,
|
||||||
|
\Carbon\CarbonInterface $generatedAt,
|
||||||
|
): string {
|
||||||
|
$package = is_array($reviewSummary['governance_package'] ?? null)
|
||||||
|
? $this->redactReportPayload($reviewSummary['governance_package'], $includePii)
|
||||||
|
: [];
|
||||||
|
$controlInterpretation = is_array($reviewSummary['control_interpretation'] ?? null)
|
||||||
|
? $reviewSummary['control_interpretation']
|
||||||
|
: [];
|
||||||
|
$nonCertificationDisclosure = $this->plainText(
|
||||||
|
$controlInterpretation['non_certification_disclosure'] ?? null,
|
||||||
|
'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.',
|
||||||
|
);
|
||||||
|
$tenantName = $includePii ? $tenant->name : '[REDACTED]';
|
||||||
|
$topFindings = is_array($package['top_findings'] ?? null) ? $package['top_findings'] : [];
|
||||||
|
$acceptedRisks = is_array($package['accepted_risks'] ?? null) ? $package['accepted_risks'] : [];
|
||||||
|
$governanceDecisions = is_array($package['governance_decisions'] ?? null) ? $package['governance_decisions'] : [];
|
||||||
|
$nextActions = is_array($reviewSummary['recommended_next_actions'] ?? null) ? $reviewSummary['recommended_next_actions'] : [];
|
||||||
|
|
||||||
|
$lines = [
|
||||||
|
'# Executive summary',
|
||||||
|
'',
|
||||||
|
'Tenant: '.$this->plainText($tenantName, '[REDACTED]'),
|
||||||
|
'Released review: #'.((int) $review->getKey()),
|
||||||
|
'Review status: '.$this->plainText($review->status, 'unknown'),
|
||||||
|
'Generated at: '.$generatedAt->toIso8601String(),
|
||||||
|
'',
|
||||||
|
'## Executive story',
|
||||||
|
'',
|
||||||
|
$this->plainText($package['executive_summary'] ?? null, 'No executive summary is available for this released review.'),
|
||||||
|
'',
|
||||||
|
'## Evidence basis',
|
||||||
|
'',
|
||||||
|
$this->plainText(
|
||||||
|
$package['evidence_basis_summary'] ?? null,
|
||||||
|
sprintf('Anchored to evidence snapshot #%d with %s completeness.', (int) $snapshot->getKey(), (string) $snapshot->completeness_state),
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
'## Key findings',
|
||||||
|
'',
|
||||||
|
...$this->entryBullets($topFindings, 'No key findings are listed for this released review.'),
|
||||||
|
'',
|
||||||
|
'## Accepted risks',
|
||||||
|
'',
|
||||||
|
...$this->entryBullets($acceptedRisks, 'No accepted risks are listed for this released review.'),
|
||||||
|
'',
|
||||||
|
'## Governance decisions requiring awareness',
|
||||||
|
'',
|
||||||
|
...$this->entryBullets($governanceDecisions, 'No governance decisions require awareness in this released review.'),
|
||||||
|
'',
|
||||||
|
'## Next actions',
|
||||||
|
'',
|
||||||
|
...$this->textBullets($nextActions, 'No next action is listed for this released review.'),
|
||||||
|
'',
|
||||||
|
'## Non-certification disclosure',
|
||||||
|
'',
|
||||||
|
$nonCertificationDisclosure,
|
||||||
|
'',
|
||||||
|
'## Structured auditor appendix',
|
||||||
|
'',
|
||||||
|
'This executive entrypoint is the first file to read. The structured auditor appendix remains available in metadata.json, summary.json, and sections.json.',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $entries
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function entryBullets(array $entries, string $emptyText): array
|
||||||
|
{
|
||||||
|
if ($entries === []) {
|
||||||
|
return ['- '.$emptyText];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($entries)
|
||||||
|
->filter(static fn (mixed $entry): bool => is_array($entry))
|
||||||
|
->map(function (array $entry): string {
|
||||||
|
$title = $this->plainText($entry['title'] ?? null, 'Entry');
|
||||||
|
$summary = $this->plainText($entry['summary'] ?? null, '');
|
||||||
|
|
||||||
|
return $summary === '' ? '- '.$title : '- '.$title.' - '.$summary;
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $entries
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function textBullets(array $entries, string $emptyText): array
|
||||||
|
{
|
||||||
|
$bullets = collect($entries)
|
||||||
|
->filter(static fn (mixed $entry): bool => is_string($entry) && trim($entry) !== '')
|
||||||
|
->map(fn (string $entry): string => '- '.$this->plainText($entry, ''))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $bullets === [] ? ['- '.$emptyText] : $bullets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function plainText(mixed $value, string $fallback): string
|
||||||
|
{
|
||||||
|
if (! is_scalar($value) && $value !== null) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = preg_replace('/\s+/', ' ', trim((string) $value));
|
||||||
|
|
||||||
|
return is_string($text) && $text !== '' ? $text : $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void
|
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void
|
||||||
{
|
{
|
||||||
$reviewPack->update([
|
$reviewPack->update([
|
||||||
|
|||||||
@ -0,0 +1,393 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs\Operations;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Intune\RestoreService;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class CrossTenantPromotionExecutionJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 420;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
public function __construct(OperationRun $operationRun)
|
||||||
|
{
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new EnsureQueuedExecutionLegitimate,
|
||||||
|
new TrackOperationRun,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
OperationRunService $operationRuns,
|
||||||
|
RestoreService $restoreService,
|
||||||
|
TargetScopeConcurrencyLimiter $limiter,
|
||||||
|
WorkspaceAuditLogger $auditLogger,
|
||||||
|
): void {
|
||||||
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
throw new RuntimeException('OperationRun is required for promotion execution.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
|
if ($this->operationRun->status === OperationRunStatus::Completed->value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->operationRun->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new RuntimeException('Promotion execution target tenant is missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
|
$targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : [];
|
||||||
|
|
||||||
|
$lock = $limiter->acquireSlot((int) $tenant->getKey(), $targetScope);
|
||||||
|
|
||||||
|
if (! $lock) {
|
||||||
|
$this->release(max(1, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3)));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$plan = is_array(data_get($context, 'promotion_execution.plan'))
|
||||||
|
? data_get($context, 'promotion_execution.plan')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (! is_array($plan)) {
|
||||||
|
throw new RuntimeException('Promotion execution plan is missing from operation context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'total' => 0,
|
||||||
|
'processed' => 0,
|
||||||
|
'succeeded' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'created' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
];
|
||||||
|
$failures = [];
|
||||||
|
|
||||||
|
$items = is_array($plan['items'] ?? null) ? array_values(array_filter($plan['items'], 'is_array')) : [];
|
||||||
|
$summary['total'] = count($items);
|
||||||
|
|
||||||
|
[$backupSet, $selectedItemIds, $preRestoreSummary, $preRestoreFailures] = $this->buildRestoreInputs(
|
||||||
|
tenant: $tenant,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
|
items: $items,
|
||||||
|
);
|
||||||
|
|
||||||
|
$summary = array_replace($summary, $preRestoreSummary);
|
||||||
|
$failures = array_merge($failures, $preRestoreFailures);
|
||||||
|
|
||||||
|
$restoreRun = null;
|
||||||
|
|
||||||
|
if ($selectedItemIds !== []) {
|
||||||
|
$restoreRun = RestoreRun::withoutEvents(fn (): RestoreRun => $restoreService->execute(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: $selectedItemIds,
|
||||||
|
dryRun: false,
|
||||||
|
actorEmail: $this->operationRun->user?->email,
|
||||||
|
actorName: $this->operationRun->initiator_name,
|
||||||
|
providerConnectionId: is_numeric($context['provider_connection_id'] ?? null) ? (int) $context['provider_connection_id'] : null,
|
||||||
|
));
|
||||||
|
|
||||||
|
RestoreRun::withoutEvents(function () use ($restoreRun): void {
|
||||||
|
$restoreRun->forceFill(['operation_run_id' => (int) $this->operationRun?->getKey()])->save();
|
||||||
|
});
|
||||||
|
|
||||||
|
[$restoreSummary, $restoreFailures] = $this->summaryFromRestoreRun($restoreRun, $items);
|
||||||
|
$summary = $this->mergeSummary($summary, $restoreSummary);
|
||||||
|
$failures = array_merge($failures, $restoreFailures);
|
||||||
|
|
||||||
|
$context['restore_run_id'] = (int) $restoreRun->getKey();
|
||||||
|
$context['backup_set_id'] = (int) $backupSet->getKey();
|
||||||
|
$this->operationRun->forceFill(['context' => $context])->save();
|
||||||
|
} else {
|
||||||
|
$backupSet?->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$outcome = $this->outcome($summary);
|
||||||
|
|
||||||
|
$updated = $operationRuns->updateRun(
|
||||||
|
run: $this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: $outcome,
|
||||||
|
summaryCounts: $summary,
|
||||||
|
failures: $failures,
|
||||||
|
);
|
||||||
|
|
||||||
|
$auditLogger->logCrossTenantPromotionExecutionCompleted(
|
||||||
|
operationRun: $updated,
|
||||||
|
sourceTenantId: is_numeric($context['source_tenant_id'] ?? null) ? (int) $context['source_tenant_id'] : null,
|
||||||
|
targetTenant: $tenant,
|
||||||
|
summaryCounts: $summary,
|
||||||
|
restoreRun: $restoreRun,
|
||||||
|
);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
throw $exception;
|
||||||
|
} finally {
|
||||||
|
$lock->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOperationRun(): ?OperationRun
|
||||||
|
{
|
||||||
|
return $this->operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $items
|
||||||
|
* @return array{0: ?BackupSet, 1: list<int>, 2: array<string, int>, 3: list<array{code: string, message: string}>}
|
||||||
|
*/
|
||||||
|
private function buildRestoreInputs(Tenant $tenant, OperationRun $operationRun, array $items): array
|
||||||
|
{
|
||||||
|
$summary = [
|
||||||
|
'processed' => 0,
|
||||||
|
'succeeded' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'created' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
];
|
||||||
|
$failures = [];
|
||||||
|
$backupSet = BackupSet::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'name' => 'Cross-tenant promotion • Operation #'.$operationRun->getKey(),
|
||||||
|
'created_by' => $operationRun->user?->email,
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 0,
|
||||||
|
'completed_at' => CarbonImmutable::now(),
|
||||||
|
'metadata' => [
|
||||||
|
'source' => 'cross_tenant_promotion',
|
||||||
|
'operation_run_id' => (int) $operationRun->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$selectedItemIds = [];
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$action = (string) ($item['execution_action'] ?? '');
|
||||||
|
|
||||||
|
if ($action === 'skip_aligned') {
|
||||||
|
$summary['processed']++;
|
||||||
|
$summary['skipped']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$versionId = data_get($item, 'source.policy_version_id');
|
||||||
|
$sourceTenantId = data_get($item, 'source.tenant_id');
|
||||||
|
|
||||||
|
$version = is_numeric($versionId) && is_numeric($sourceTenantId)
|
||||||
|
? PolicyVersion::query()
|
||||||
|
->with('policy')
|
||||||
|
->whereKey((int) $versionId)
|
||||||
|
->where('tenant_id', (int) $sourceTenantId)
|
||||||
|
->first()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (! $version instanceof PolicyVersion || ! $version->policy instanceof Policy) {
|
||||||
|
$summary['processed']++;
|
||||||
|
$summary['failed']++;
|
||||||
|
$failures[] = [
|
||||||
|
'code' => 'promotion.source_version_missing',
|
||||||
|
'message' => 'Source policy version for '.$this->itemLabel($item).' was not found.',
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourcePolicy = $version->policy;
|
||||||
|
$targetExternalId = data_get($item, 'target.subject_external_id');
|
||||||
|
$sourceExternalId = data_get($item, 'source.subject_external_id');
|
||||||
|
$policyIdentifier = is_string($targetExternalId) && trim($targetExternalId) !== ''
|
||||||
|
? trim($targetExternalId)
|
||||||
|
: (is_string($sourceExternalId) && trim($sourceExternalId) !== '' ? trim($sourceExternalId) : (string) $sourcePolicy->external_id);
|
||||||
|
|
||||||
|
$targetPolicy = Policy::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('policy_type', (string) $sourcePolicy->policy_type)
|
||||||
|
->where('external_id', $policyIdentifier)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$backupItem = BackupItem::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'policy_id' => $targetPolicy?->getKey(),
|
||||||
|
'policy_identifier' => $policyIdentifier,
|
||||||
|
'policy_type' => (string) $sourcePolicy->policy_type,
|
||||||
|
'platform' => (string) $sourcePolicy->platform,
|
||||||
|
'captured_at' => $version->captured_at ?? CarbonImmutable::now(),
|
||||||
|
'payload' => is_array($version->snapshot) ? $version->snapshot : [],
|
||||||
|
'metadata' => [
|
||||||
|
'source' => 'cross_tenant_promotion',
|
||||||
|
'display_name' => (string) $sourcePolicy->display_name,
|
||||||
|
'operation_run_id' => (int) $operationRun->getKey(),
|
||||||
|
'source_tenant_id' => (int) $sourcePolicy->tenant_id,
|
||||||
|
'source_policy_id' => (int) $sourcePolicy->getKey(),
|
||||||
|
'source_policy_version_id' => (int) $version->getKey(),
|
||||||
|
'source_subject_key' => (string) ($item['subject_key'] ?? ''),
|
||||||
|
'execution_action' => $action,
|
||||||
|
'target_subject_external_id' => is_string($targetExternalId) ? $targetExternalId : null,
|
||||||
|
],
|
||||||
|
'assignments' => is_array($version->assignments) ? $version->assignments : [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$selectedItemIds[] = (int) $backupItem->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupSet->forceFill(['item_count' => count($selectedItemIds)])->save();
|
||||||
|
|
||||||
|
return [$backupSet, $selectedItemIds, $summary, $failures];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $items
|
||||||
|
* @return array{0: array<string, int>, 1: list<array{code: string, message: string}>}
|
||||||
|
*/
|
||||||
|
private function summaryFromRestoreRun(RestoreRun $restoreRun, array $items): array
|
||||||
|
{
|
||||||
|
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
|
||||||
|
$results = is_array($restoreRun->results) ? $restoreRun->results : [];
|
||||||
|
$resultItems = is_array($results['items'] ?? null) ? $results['items'] : [];
|
||||||
|
$succeeded = (int) ($metadata['succeeded'] ?? 0);
|
||||||
|
$failed = (int) ($metadata['failed'] ?? 0) + (int) ($metadata['partial'] ?? 0);
|
||||||
|
$skipped = (int) ($metadata['skipped'] ?? 0);
|
||||||
|
$processed = $succeeded + $failed + $skipped;
|
||||||
|
$created = 0;
|
||||||
|
$updated = 0;
|
||||||
|
$failures = [];
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$action = (string) ($item['execution_action'] ?? '');
|
||||||
|
|
||||||
|
if ($action === 'create_missing') {
|
||||||
|
$created++;
|
||||||
|
} elseif ($action === 'update_existing') {
|
||||||
|
$updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($resultItems as $result) {
|
||||||
|
if (! is_array($result)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) ($result['status'] ?? '');
|
||||||
|
|
||||||
|
if (in_array($status, ['applied', 'dry_run'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$failures[] = [
|
||||||
|
'code' => 'promotion.restore_item_not_applied',
|
||||||
|
'message' => (string) ($result['reason'] ?? 'Promotion restore item did not apply.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [[
|
||||||
|
'processed' => $processed,
|
||||||
|
'succeeded' => $succeeded,
|
||||||
|
'failed' => $failed,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'created' => min($created, $succeeded),
|
||||||
|
'updated' => min($updated, max(0, $succeeded - min($created, $succeeded))),
|
||||||
|
], $failures];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $left
|
||||||
|
* @param array<string, int> $right
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function mergeSummary(array $left, array $right): array
|
||||||
|
{
|
||||||
|
foreach ($right as $key => $value) {
|
||||||
|
$left[$key] = (int) ($left[$key] ?? 0) + (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $summary
|
||||||
|
*/
|
||||||
|
private function outcome(array $summary): string
|
||||||
|
{
|
||||||
|
$total = (int) ($summary['total'] ?? 0);
|
||||||
|
$failed = (int) ($summary['failed'] ?? 0);
|
||||||
|
$succeeded = (int) ($summary['succeeded'] ?? 0);
|
||||||
|
$skipped = (int) ($summary['skipped'] ?? 0);
|
||||||
|
|
||||||
|
if ($total > 0 && $failed >= $total) {
|
||||||
|
return OperationRunOutcome::Failed->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failed > 0) {
|
||||||
|
return OperationRunOutcome::PartiallySucceeded->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($succeeded > 0 || $skipped > 0) {
|
||||||
|
return OperationRunOutcome::Succeeded->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationRunOutcome::Failed->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $item
|
||||||
|
*/
|
||||||
|
private function itemLabel(array $item): string
|
||||||
|
{
|
||||||
|
$displayName = (string) ($item['display_name'] ?? '');
|
||||||
|
|
||||||
|
if ($displayName !== '') {
|
||||||
|
return $displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) ($item['subject_key'] ?? 'unknown subject');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -94,6 +94,19 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
|
|||||||
? array_values(array_unique(array_merge($policyTypes, $foundationTypes)))
|
? array_values(array_unique(array_merge($policyTypes, $foundationTypes)))
|
||||||
: array_values(array_diff($policyTypes, $foundationTypes));
|
: array_values(array_diff($policyTypes, $foundationTypes));
|
||||||
|
|
||||||
|
$this->operationRun = $operationRunService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Running->value,
|
||||||
|
outcome: OperationRunOutcome::Pending->value,
|
||||||
|
summaryCounts: [
|
||||||
|
'total' => count($attemptedTypes),
|
||||||
|
'processed' => 0,
|
||||||
|
'succeeded' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
$processedPolicyTypes = [];
|
$processedPolicyTypes = [];
|
||||||
$coverageStatusByType = [];
|
$coverageStatusByType = [];
|
||||||
$successCount = 0;
|
$successCount = 0;
|
||||||
@ -103,7 +116,7 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
|
|||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
$tenant,
|
$tenant,
|
||||||
$context,
|
$context,
|
||||||
function (string $policyType, bool $success, ?string $errorCode, int $itemCount) use (&$processedPolicyTypes, &$coverageStatusByType, &$successCount, &$failedCount): void {
|
function (string $policyType, bool $success, ?string $errorCode, int $itemCount) use ($operationRunService, &$processedPolicyTypes, &$coverageStatusByType, &$successCount, &$failedCount): void {
|
||||||
$processedPolicyTypes[] = $policyType;
|
$processedPolicyTypes[] = $policyType;
|
||||||
$coverageStatusByType[$policyType] = array_filter([
|
$coverageStatusByType[$policyType] = array_filter([
|
||||||
'status' => $success
|
'status' => $success
|
||||||
@ -116,10 +129,20 @@ function (string $policyType, bool $success, ?string $errorCode, int $itemCount)
|
|||||||
if ($success) {
|
if ($success) {
|
||||||
$successCount++;
|
$successCount++;
|
||||||
|
|
||||||
|
$this->operationRun = $operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||||
|
'processed' => 1,
|
||||||
|
'succeeded' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$failedCount++;
|
$failedCount++;
|
||||||
|
|
||||||
|
$this->operationRun = $operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||||
|
'processed' => 1,
|
||||||
|
'failed' => 1,
|
||||||
|
]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -210,6 +233,7 @@ function (string $policyType, bool $success, ?string $errorCode, int $itemCount)
|
|||||||
$itemsObserved = (int) ($result['items_observed_count'] ?? 0);
|
$itemsObserved = (int) ($result['items_observed_count'] ?? 0);
|
||||||
$itemsUpserted = (int) ($result['items_upserted_count'] ?? 0);
|
$itemsUpserted = (int) ($result['items_upserted_count'] ?? 0);
|
||||||
$errorsCount = (int) ($result['errors_count'] ?? 0);
|
$errorsCount = (int) ($result['errors_count'] ?? 0);
|
||||||
|
$attemptedTypeCount = count($attemptedTypes);
|
||||||
|
|
||||||
if ($status === 'success') {
|
if ($status === 'success') {
|
||||||
$operationRunService->updateRun(
|
$operationRunService->updateRun(
|
||||||
@ -217,9 +241,9 @@ function (string $policyType, bool $success, ?string $errorCode, int $itemCount)
|
|||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
summaryCounts: [
|
summaryCounts: [
|
||||||
'total' => count($policyTypes),
|
'total' => $attemptedTypeCount,
|
||||||
'processed' => count($policyTypes),
|
'processed' => $attemptedTypeCount,
|
||||||
'succeeded' => count($policyTypes),
|
'succeeded' => $attemptedTypeCount,
|
||||||
'failed' => 0,
|
'failed' => 0,
|
||||||
'items' => $itemsObserved,
|
'items' => $itemsObserved,
|
||||||
'updated' => $itemsUpserted,
|
'updated' => $itemsUpserted,
|
||||||
@ -247,15 +271,17 @@ function (string $policyType, bool $success, ?string $errorCode, int $itemCount)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($status === 'partial') {
|
if ($status === 'partial') {
|
||||||
|
$failedUnits = max($failedCount, $errorsCount);
|
||||||
|
|
||||||
$operationRunService->updateRun(
|
$operationRunService->updateRun(
|
||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::PartiallySucceeded->value,
|
outcome: OperationRunOutcome::PartiallySucceeded->value,
|
||||||
summaryCounts: [
|
summaryCounts: [
|
||||||
'total' => count($policyTypes),
|
'total' => $attemptedTypeCount,
|
||||||
'processed' => count($policyTypes),
|
'processed' => $attemptedTypeCount,
|
||||||
'succeeded' => max(0, count($policyTypes) - $errorsCount),
|
'succeeded' => max(0, $attemptedTypeCount - $failedUnits),
|
||||||
'failed' => $errorsCount,
|
'failed' => $failedUnits,
|
||||||
'items' => $itemsObserved,
|
'items' => $itemsObserved,
|
||||||
'updated' => $itemsUpserted,
|
'updated' => $itemsUpserted,
|
||||||
],
|
],
|
||||||
@ -292,11 +318,11 @@ function (string $policyType, bool $success, ?string $errorCode, int $itemCount)
|
|||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
summaryCounts: [
|
summaryCounts: [
|
||||||
'total' => count($policyTypes),
|
'total' => $attemptedTypeCount,
|
||||||
'processed' => count($policyTypes),
|
'processed' => $attemptedTypeCount,
|
||||||
'succeeded' => 0,
|
'succeeded' => 0,
|
||||||
'failed' => 0,
|
'failed' => 0,
|
||||||
'skipped' => count($policyTypes),
|
'skipped' => $attemptedTypeCount,
|
||||||
],
|
],
|
||||||
failures: [
|
failures: [
|
||||||
['code' => 'inventory.skipped', 'message' => $reason],
|
['code' => 'inventory.skipped', 'message' => $reason],
|
||||||
@ -322,17 +348,18 @@ function (string $policyType, bool $success, ?string $errorCode, int $itemCount)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$missingPolicyTypes = array_values(array_diff($policyTypes, array_unique($processedPolicyTypes)));
|
$missingPolicyTypes = array_values(array_diff($attemptedTypes, array_unique($processedPolicyTypes)));
|
||||||
|
$failedUnits = $failedCount + count($missingPolicyTypes);
|
||||||
|
|
||||||
$operationRunService->updateRun(
|
$operationRunService->updateRun(
|
||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
summaryCounts: [
|
summaryCounts: [
|
||||||
'total' => count($policyTypes),
|
'total' => $attemptedTypeCount,
|
||||||
'processed' => count($policyTypes),
|
'processed' => $successCount + $failedUnits,
|
||||||
'succeeded' => $successCount,
|
'succeeded' => $successCount,
|
||||||
'failed' => max($failedCount, count($missingPolicyTypes)),
|
'failed' => $failedUnits,
|
||||||
],
|
],
|
||||||
failures: [
|
failures: [
|
||||||
['code' => 'inventory.failed', 'reason_code' => $reasonCode, 'message' => $reason],
|
['code' => 'inventory.failed', 'reason_code' => $reasonCode, 'message' => $reason],
|
||||||
|
|||||||
@ -21,7 +21,6 @@
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Filters\TernaryFilter;
|
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Filament\Tables\TableComponent;
|
use Filament\Tables\TableComponent;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
@ -98,6 +97,17 @@ public function table(Table $table): Table
|
|||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
TextColumn::make('visibility_state')
|
||||||
|
->label('Visibility')
|
||||||
|
->badge()
|
||||||
|
->state(fn (Policy $record): string => $record->visibilityState())
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyProviderPresence))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::PolicyProviderPresence))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicyProviderPresence))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyProviderPresence))
|
||||||
|
->description(fn (Policy $record): ?string => $record->isCurrentBackupEligible()
|
||||||
|
? null
|
||||||
|
: $record->currentBackupBlockedReasonLabel()),
|
||||||
TextColumn::make('external_id')
|
TextColumn::make('external_id')
|
||||||
->label('External ID')
|
->label('External ID')
|
||||||
->formatStateUsing(fn (?string $state): string => static::externalIdShort($state))
|
->formatStateUsing(fn (?string $state): string => static::externalIdShort($state))
|
||||||
@ -146,7 +156,7 @@ public function table(Table $table): Table
|
|||||||
'90' => 'Within 90 days',
|
'90' => 'Within 90 days',
|
||||||
'any' => 'Any time',
|
'any' => 'Any time',
|
||||||
])
|
])
|
||||||
->default('7')
|
->default('any')
|
||||||
->query(function (Builder $query, array $data): Builder {
|
->query(function (Builder $query, array $data): Builder {
|
||||||
$value = (string) ($data['value'] ?? '7');
|
$value = (string) ($data['value'] ?? '7');
|
||||||
|
|
||||||
@ -158,14 +168,28 @@ public function table(Table $table): Table
|
|||||||
|
|
||||||
return $query->where('last_synced_at', '>', now()->subDays(max(1, $days)));
|
return $query->where('last_synced_at', '>', now()->subDays(max(1, $days)));
|
||||||
}),
|
}),
|
||||||
TernaryFilter::make('ignored')
|
SelectFilter::make('visibility')
|
||||||
->label('Ignored')
|
->label('Visibility')
|
||||||
->nullable()
|
->options([
|
||||||
->queries(
|
'active' => 'Active',
|
||||||
true: fn (Builder $query) => $query->whereNotNull('ignored_at'),
|
'ignored' => 'Ignored locally',
|
||||||
false: fn (Builder $query) => $query->whereNull('ignored_at'),
|
'provider_missing' => 'Provider missing',
|
||||||
)
|
'all' => 'All',
|
||||||
->default(false),
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
|
if (blank($value) || $value === 'all') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($value) {
|
||||||
|
'active' => $query->active(),
|
||||||
|
'ignored' => $query->whereNotNull('ignored_at'),
|
||||||
|
'provider_missing' => $query->whereNotNull('missing_from_provider_at'),
|
||||||
|
default => $query,
|
||||||
|
};
|
||||||
|
}),
|
||||||
SelectFilter::make('has_versions')
|
SelectFilter::make('has_versions')
|
||||||
->label('Has versions')
|
->label('Has versions')
|
||||||
->options([
|
->options([
|
||||||
@ -188,6 +212,7 @@ public function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->emptyStateHeading('No matching policies available')
|
->emptyStateHeading('No matching policies available')
|
||||||
->emptyStateDescription('Adjust the current filters or sync additional policies before adding them to this backup set.')
|
->emptyStateDescription('Adjust the current filters or sync additional policies before adding them to this backup set.')
|
||||||
|
->checkIfRecordIsSelectableUsing(fn (Policy $record): bool => $record->isCurrentBackupEligible())
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkAction::make('add_selected_to_backup_set')
|
BulkAction::make('add_selected_to_backup_set')
|
||||||
->label('Add selected')
|
->label('Add selected')
|
||||||
@ -285,6 +310,20 @@ public function table(Table $table): Table
|
|||||||
|
|
||||||
sort($policyIds);
|
sort($policyIds);
|
||||||
|
|
||||||
|
$blocked = $records->first(
|
||||||
|
fn ($record): bool => $record instanceof Policy && ! $record->isCurrentBackupEligible()
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($blocked instanceof Policy) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Current backup unavailable')
|
||||||
|
->body($blocked->currentBackupBlockedReasonLabel())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
$selectionIdentity = $selection->fromIds($policyIds);
|
$selectionIdentity = $selection->fromIds($policyIds);
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@ -13,6 +14,8 @@
|
|||||||
|
|
||||||
class BulkOperationProgress extends Component
|
class BulkOperationProgress extends Component
|
||||||
{
|
{
|
||||||
|
private const VISIBLE_RUN_LIMIT = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Collection<int, OperationRun>
|
* @var Collection<int, OperationRun>
|
||||||
*/
|
*/
|
||||||
@ -24,6 +27,10 @@ class BulkOperationProgress extends Component
|
|||||||
|
|
||||||
public bool $hasActiveRuns = false;
|
public bool $hasActiveRuns = false;
|
||||||
|
|
||||||
|
public bool $hasVisibleRuns = false;
|
||||||
|
|
||||||
|
public int $activeRunCount = 0;
|
||||||
|
|
||||||
public ?int $tenantId = null;
|
public ?int $tenantId = null;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
@ -47,7 +54,7 @@ public function onRunEnqueued(?int $tenantId = null): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function activeRuns()
|
public function activeRuns(): Collection
|
||||||
{
|
{
|
||||||
return $this->runs;
|
return $this->runs;
|
||||||
}
|
}
|
||||||
@ -83,15 +90,14 @@ public function refreshRuns(): void
|
|||||||
|
|
||||||
$this->disabled = false;
|
$this->disabled = false;
|
||||||
|
|
||||||
$query = OperationRun::query()
|
$activeCount = ActiveRuns::countForTenantId($tenantId);
|
||||||
->where('tenant_id', $tenantId)
|
$visibleCount = ActiveRuns::shellVisibleCountForTenantId($tenantId);
|
||||||
->active()
|
|
||||||
->orderByDesc('created_at');
|
|
||||||
|
|
||||||
$activeCount = (clone $query)->count();
|
$this->activeRunCount = $activeCount;
|
||||||
$this->runs = (clone $query)->limit(6)->get();
|
$this->runs = ActiveRuns::shellVisibleForTenantId($tenantId, self::VISIBLE_RUN_LIMIT);
|
||||||
$this->overflowCount = max(0, $activeCount - 5);
|
$this->overflowCount = max(0, $visibleCount - self::VISIBLE_RUN_LIMIT);
|
||||||
$this->hasActiveRuns = $activeCount > 0;
|
$this->hasActiveRuns = $activeCount > 0;
|
||||||
|
$this->hasVisibleRuns = $visibleCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(): \Illuminate\Contracts\View\View
|
public function render(): \Illuminate\Contracts\View\View
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||||
use App\Support\Concerns\InteractsWithODataTypes;
|
use App\Support\Concerns\InteractsWithODataTypes;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
@ -15,12 +16,21 @@ class Policy extends Model
|
|||||||
use HasFactory;
|
use HasFactory;
|
||||||
use InteractsWithODataTypes;
|
use InteractsWithODataTypes;
|
||||||
|
|
||||||
|
public const VISIBILITY_ACTIVE = 'active';
|
||||||
|
|
||||||
|
public const VISIBILITY_IGNORED_LOCALLY = 'ignored_locally';
|
||||||
|
|
||||||
|
public const VISIBILITY_PROVIDER_MISSING = 'provider_missing';
|
||||||
|
|
||||||
|
public const VISIBILITY_IGNORED_LOCALLY_PROVIDER_MISSING = 'ignored_locally_provider_missing';
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'metadata' => 'array',
|
'metadata' => 'array',
|
||||||
'last_synced_at' => 'datetime',
|
'last_synced_at' => 'datetime',
|
||||||
'ignored_at' => 'datetime',
|
'ignored_at' => 'datetime',
|
||||||
|
'missing_from_provider_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
public function tenant(): BelongsTo
|
||||||
@ -38,16 +48,77 @@ public function backupItems(): HasMany
|
|||||||
return $this->hasMany(BackupItem::class);
|
return $this->hasMany(BackupItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeActive($query)
|
public function scopeActive(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->whereNull('ignored_at');
|
return $query
|
||||||
|
->whereNull('ignored_at')
|
||||||
|
->whereNull('missing_from_provider_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeIgnored($query)
|
public function scopeIgnored(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->whereNotNull('ignored_at');
|
return $query->whereNotNull('ignored_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeProviderMissing(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('missing_from_provider_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeCurrentBackupEligible(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->whereNull('ignored_at')
|
||||||
|
->whereNull('missing_from_provider_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isIgnoredLocally(): bool
|
||||||
|
{
|
||||||
|
return $this->ignored_at !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isProviderMissing(): bool
|
||||||
|
{
|
||||||
|
return $this->missing_from_provider_at !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function visibilityState(): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$this->isIgnoredLocally() && $this->isProviderMissing() => self::VISIBILITY_IGNORED_LOCALLY_PROVIDER_MISSING,
|
||||||
|
$this->isIgnoredLocally() => self::VISIBILITY_IGNORED_LOCALLY,
|
||||||
|
$this->isProviderMissing() => self::VISIBILITY_PROVIDER_MISSING,
|
||||||
|
default => self::VISIBILITY_ACTIVE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCurrentBackupEligible(): bool
|
||||||
|
{
|
||||||
|
return ! $this->isIgnoredLocally() && ! $this->isProviderMissing();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentBackupBlockedReason(): ?string
|
||||||
|
{
|
||||||
|
if ($this->isProviderMissing()) {
|
||||||
|
return self::VISIBILITY_PROVIDER_MISSING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isIgnoredLocally()) {
|
||||||
|
return self::VISIBILITY_IGNORED_LOCALLY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentBackupBlockedReasonLabel(): ?string
|
||||||
|
{
|
||||||
|
return match ($this->currentBackupBlockedReason()) {
|
||||||
|
self::VISIBILITY_PROVIDER_MISSING => 'Provider missing - current provider-backed capture is unavailable.',
|
||||||
|
self::VISIBILITY_IGNORED_LOCALLY => 'Ignored locally - restore local visibility before fresh capture.',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public function ignore(): void
|
public function ignore(): void
|
||||||
{
|
{
|
||||||
$this->update(['ignored_at' => now()]);
|
$this->update(['ignored_at' => now()]);
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||||
|
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
||||||
use App\Support\TenantReviewCompletenessState;
|
use App\Support\TenantReviewCompletenessState;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\TenantReviewStatus;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -205,4 +206,63 @@ public function canonicalControlReferences(): array
|
|||||||
? array_values(array_filter($references, static fn (mixed $reference): bool => is_array($reference)))
|
? array_values(array_filter($references, static fn (mixed $reference): bool => is_array($reference)))
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function controlInterpretation(): array
|
||||||
|
{
|
||||||
|
$summary = is_array($this->summary) ? $this->summary : [];
|
||||||
|
$interpretation = $summary['control_interpretation'] ?? [];
|
||||||
|
|
||||||
|
return is_array($interpretation) ? $interpretation : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function controlInterpretationVersion(): ?string
|
||||||
|
{
|
||||||
|
$version = $this->controlInterpretation()['version_key'] ?? null;
|
||||||
|
|
||||||
|
return is_string($version) && trim($version) !== '' ? $version : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function controlInterpretationControls(): array
|
||||||
|
{
|
||||||
|
$controls = $this->controlInterpretation()['controls'] ?? [];
|
||||||
|
|
||||||
|
return is_array($controls)
|
||||||
|
? array_values(array_filter($controls, static fn (mixed $control): bool => is_array($control)))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
public function controlInterpretationLimitationCounts(): array
|
||||||
|
{
|
||||||
|
$counts = $this->controlInterpretation()['limitation_counts'] ?? [];
|
||||||
|
|
||||||
|
if (! is_array($counts)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($counts)
|
||||||
|
->mapWithKeys(static fn (mixed $count, string|int $key): array => [(string) $key => (int) $count])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function controlInterpretationSection(): ?TenantReviewSection
|
||||||
|
{
|
||||||
|
if ($this->relationLoaded('sections')) {
|
||||||
|
$section = $this->sections->firstWhere('section_key', ComplianceEvidenceMappingV1::SECTION_KEY);
|
||||||
|
|
||||||
|
return $section instanceof TenantReviewSection ? $section : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sections()
|
||||||
|
->where('section_key', ComplianceEvidenceMappingV1::SECTION_KEY)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Support\TenantReviewCompletenessState;
|
use App\Support\TenantReviewCompletenessState;
|
||||||
|
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -67,4 +68,26 @@ public function completenessEnum(): TenantReviewCompletenessState
|
|||||||
return TenantReviewCompletenessState::tryFrom((string) $this->completeness_state)
|
return TenantReviewCompletenessState::tryFrom((string) $this->completeness_state)
|
||||||
?? TenantReviewCompletenessState::Missing;
|
?? TenantReviewCompletenessState::Missing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isControlInterpretation(): bool
|
||||||
|
{
|
||||||
|
return (string) $this->section_key === ComplianceEvidenceMappingV1::SECTION_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function controlInterpretationEntries(): array
|
||||||
|
{
|
||||||
|
if (! $this->isControlInterpretation()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderPayload = is_array($this->render_payload) ? $this->render_payload : [];
|
||||||
|
$entries = $renderPayload['entries'] ?? [];
|
||||||
|
|
||||||
|
return is_array($entries)
|
||||||
|
? array_values(array_filter($entries, static fn (mixed $entry): bool => is_array($entry)))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
class Workspace extends Model
|
class Workspace extends Model
|
||||||
{
|
{
|
||||||
@ -49,6 +50,14 @@ public function settings(): HasMany
|
|||||||
return $this->hasMany(WorkspaceSetting::class);
|
return $this->hasMany(WorkspaceSetting::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasOne<WorkspaceSubscription, $this>
|
||||||
|
*/
|
||||||
|
public function subscription(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(WorkspaceSubscription::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return HasMany<TenantSetting, $this>
|
* @return HasMany<TenantSetting, $this>
|
||||||
*/
|
*/
|
||||||
|
|||||||
57
apps/platform/app/Models/WorkspaceSubscription.php
Normal file
57
apps/platform/app/Models/WorkspaceSubscription.php
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class WorkspaceSubscription extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const STATE_TRIAL = 'trial';
|
||||||
|
|
||||||
|
public const STATE_ACTIVE = 'active';
|
||||||
|
|
||||||
|
public const STATE_PAST_DUE = 'past_due';
|
||||||
|
|
||||||
|
public const STATE_CANCEL_AT_PERIOD_END = 'cancel_at_period_end';
|
||||||
|
|
||||||
|
public const STATE_ENDED = 'ended';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function stateIds(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::STATE_TRIAL,
|
||||||
|
self::STATE_ACTIVE,
|
||||||
|
self::STATE_PAST_DUE,
|
||||||
|
self::STATE_CANCEL_AT_PERIOD_END,
|
||||||
|
self::STATE_ENDED,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'trial_ends_at' => 'datetime',
|
||||||
|
'current_period_starts_at' => 'datetime',
|
||||||
|
'current_period_ends_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Workspace, $this>
|
||||||
|
*/
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Filament\Pages\CrossTenantComparePage;
|
use App\Filament\Pages\CrossTenantComparePage;
|
||||||
use App\Filament\Pages\Findings\FindingsHygieneReport;
|
use App\Filament\Pages\Findings\FindingsHygieneReport;
|
||||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
||||||
|
use App\Filament\Pages\Governance\DecisionRegister;
|
||||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||||
use App\Filament\Pages\InventoryCoverage;
|
use App\Filament\Pages\InventoryCoverage;
|
||||||
@ -184,6 +185,7 @@ public function panel(Panel $panel): Panel
|
|||||||
WorkspaceSettings::class,
|
WorkspaceSettings::class,
|
||||||
CrossTenantComparePage::class,
|
CrossTenantComparePage::class,
|
||||||
GovernanceInbox::class,
|
GovernanceInbox::class,
|
||||||
|
DecisionRegister::class,
|
||||||
FindingsHygieneReport::class,
|
FindingsHygieneReport::class,
|
||||||
FindingsIntakeQueue::class,
|
FindingsIntakeQueue::class,
|
||||||
MyFindingsInbox::class,
|
MyFindingsInbox::class,
|
||||||
|
|||||||
@ -75,8 +75,8 @@ public function panel(Panel $panel): Panel
|
|||||||
fn () => view('filament.partials.context-bar')->render()
|
fn () => view('filament.partials.context-bar')->render()
|
||||||
)
|
)
|
||||||
->renderHook(
|
->renderHook(
|
||||||
PanelsRenderHook::BODY_END,
|
PanelsRenderHook::CONTENT_START,
|
||||||
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
|
fn (): string => $this->shouldRenderBulkOperationProgressWidget()
|
||||||
? view('livewire.bulk-operation-progress-wrapper')->render()
|
? view('livewire.bulk-operation-progress-wrapper')->render()
|
||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
@ -116,14 +116,27 @@ public function panel(Panel $panel): Panel
|
|||||||
Authenticate::class,
|
Authenticate::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! app()->runningUnitTests()) {
|
$theme = PanelThemeAsset::resolve('resources/css/filament/admin/theme.css');
|
||||||
$theme = PanelThemeAsset::resolve('resources/css/filament/admin/theme.css');
|
|
||||||
|
|
||||||
if (is_string($theme)) {
|
if (is_string($theme)) {
|
||||||
$panel->theme($theme);
|
$panel->theme($theme);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $panel;
|
return $panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldRenderBulkOperationProgressWidget(): bool
|
||||||
|
{
|
||||||
|
if (! (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$segments = request()->segments();
|
||||||
|
|
||||||
|
return ! (
|
||||||
|
count($segments) === 3
|
||||||
|
&& ($segments[0] ?? null) === 'admin'
|
||||||
|
&& ($segments[1] ?? null) === 't'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
use App\Models\SupportRequest;
|
use App\Models\SupportRequest;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -176,6 +177,85 @@ public function logCrossTenantPromotionPreflightGenerated(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $plan
|
||||||
|
*/
|
||||||
|
public function logCrossTenantPromotionExecutionQueued(
|
||||||
|
Workspace $workspace,
|
||||||
|
Tenant $sourceTenant,
|
||||||
|
Tenant $targetTenant,
|
||||||
|
OperationRun $operationRun,
|
||||||
|
array $plan,
|
||||||
|
User|PlatformUser|null $actor = null,
|
||||||
|
): \App\Models\AuditLog {
|
||||||
|
$summary = is_array($plan['summary'] ?? null) ? $plan['summary'] : [];
|
||||||
|
|
||||||
|
return $this->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::CrossTenantPromotionExecutionQueued,
|
||||||
|
context: [
|
||||||
|
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||||
|
'source_tenant_name' => (string) $sourceTenant->name,
|
||||||
|
'target_tenant_id' => (int) $targetTenant->getKey(),
|
||||||
|
'target_tenant_name' => (string) $targetTenant->name,
|
||||||
|
'selection' => is_array($plan['selection'] ?? null) ? $plan['selection'] : [],
|
||||||
|
'ready_count' => (int) ($summary['ready'] ?? 0),
|
||||||
|
'excluded_count' => (int) ($summary['excluded'] ?? 0),
|
||||||
|
'created_count' => (int) ($summary['created'] ?? 0),
|
||||||
|
'updated_count' => (int) ($summary['updated'] ?? 0),
|
||||||
|
],
|
||||||
|
actor: $actor,
|
||||||
|
status: 'queued',
|
||||||
|
resourceType: 'operation_run',
|
||||||
|
resourceId: (string) $operationRun->getKey(),
|
||||||
|
targetLabel: $sourceTenant->name.' -> '.$targetTenant->name,
|
||||||
|
summary: 'Cross-tenant promotion execution queued for '.$sourceTenant->name.' -> '.$targetTenant->name,
|
||||||
|
operationRunId: (int) $operationRun->getKey(),
|
||||||
|
tenant: $targetTenant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $summaryCounts
|
||||||
|
*/
|
||||||
|
public function logCrossTenantPromotionExecutionCompleted(
|
||||||
|
OperationRun $operationRun,
|
||||||
|
?int $sourceTenantId,
|
||||||
|
Tenant $targetTenant,
|
||||||
|
array $summaryCounts,
|
||||||
|
?RestoreRun $restoreRun = null,
|
||||||
|
): \App\Models\AuditLog {
|
||||||
|
$context = is_array($operationRun->context) ? $operationRun->context : [];
|
||||||
|
$sourceTenantName = is_string($context['source_tenant_name'] ?? null)
|
||||||
|
? (string) $context['source_tenant_name']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return $this->log(
|
||||||
|
workspace: $targetTenant->workspace,
|
||||||
|
action: AuditActionId::CrossTenantPromotionExecutionCompleted,
|
||||||
|
context: [
|
||||||
|
'source_tenant_id' => $sourceTenantId,
|
||||||
|
'source_tenant_name' => $sourceTenantName,
|
||||||
|
'target_tenant_id' => (int) $targetTenant->getKey(),
|
||||||
|
'target_tenant_name' => (string) $targetTenant->name,
|
||||||
|
'summary_counts' => $summaryCounts,
|
||||||
|
'restore_run_id' => $restoreRun?->getKey(),
|
||||||
|
'operation_outcome' => (string) $operationRun->outcome,
|
||||||
|
],
|
||||||
|
status: match ((string) $operationRun->outcome) {
|
||||||
|
'failed' => 'failed',
|
||||||
|
'partially_succeeded' => 'partial',
|
||||||
|
default => 'success',
|
||||||
|
},
|
||||||
|
resourceType: 'operation_run',
|
||||||
|
resourceId: (string) $operationRun->getKey(),
|
||||||
|
targetLabel: ($sourceTenantName !== null ? $sourceTenantName.' -> ' : '').$targetTenant->name,
|
||||||
|
summary: 'Cross-tenant promotion execution completed for '.(($sourceTenantName !== null ? $sourceTenantName.' -> ' : '')).$targetTenant->name,
|
||||||
|
operationRunId: (int) $operationRun->getKey(),
|
||||||
|
tenant: $targetTenant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function logSupportRequestCreated(
|
public function logSupportRequestCreated(
|
||||||
SupportRequest $supportRequest,
|
SupportRequest $supportRequest,
|
||||||
User|PlatformUser|null $actor = null,
|
User|PlatformUser|null $actor = null,
|
||||||
|
|||||||
@ -84,6 +84,12 @@ public function startCapture(
|
|||||||
'entra_tenant_id' => $sourceTenant->graphTenantId(),
|
'entra_tenant_id' => $sourceTenant->graphTenantId(),
|
||||||
'entra_tenant_name' => (string) $sourceTenant->name,
|
'entra_tenant_name' => (string) $sourceTenant->name,
|
||||||
],
|
],
|
||||||
|
'progress' => [
|
||||||
|
'phase' => [
|
||||||
|
'key' => 'preparing',
|
||||||
|
'label' => 'Preparing baseline capture.',
|
||||||
|
],
|
||||||
|
],
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
|
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
|
||||||
|
|||||||
@ -144,6 +144,12 @@ public function startCompareForProfile(
|
|||||||
'entra_tenant_id' => $tenant->graphTenantId(),
|
'entra_tenant_id' => $tenant->graphTenantId(),
|
||||||
'entra_tenant_name' => (string) $tenant->name,
|
'entra_tenant_name' => (string) $tenant->name,
|
||||||
],
|
],
|
||||||
|
'progress' => [
|
||||||
|
'phase' => [
|
||||||
|
'key' => 'preparing',
|
||||||
|
'label' => 'Preparing baseline comparison.',
|
||||||
|
],
|
||||||
|
],
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'baseline_snapshot_id' => $snapshotId,
|
'baseline_snapshot_id' => $snapshotId,
|
||||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
|
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceSetting;
|
use App\Models\WorkspaceSetting;
|
||||||
use App\Services\Settings\SettingsResolver;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use Carbon\CarbonInterface;
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
@ -32,6 +31,8 @@ final class WorkspaceCommercialLifecycleResolver
|
|||||||
|
|
||||||
public const SOURCE_WORKSPACE_SETTING = 'workspace_setting';
|
public const SOURCE_WORKSPACE_SETTING = 'workspace_setting';
|
||||||
|
|
||||||
|
public const SOURCE_WORKSPACE_SUBSCRIPTION = WorkspaceSubscriptionResolver::SOURCE_WORKSPACE_SUBSCRIPTION;
|
||||||
|
|
||||||
public const ACTION_MANAGED_TENANT_ACTIVATION = 'managed_tenant_activation';
|
public const ACTION_MANAGED_TENANT_ACTIVATION = 'managed_tenant_activation';
|
||||||
|
|
||||||
public const ACTION_REVIEW_PACK_START = 'review_pack_start';
|
public const ACTION_REVIEW_PACK_START = 'review_pack_start';
|
||||||
@ -55,8 +56,8 @@ final class WorkspaceCommercialLifecycleResolver
|
|||||||
public const REASON_FAMILY_COMMERCIAL_LIFECYCLE = 'commercial_lifecycle';
|
public const REASON_FAMILY_COMMERCIAL_LIFECYCLE = 'commercial_lifecycle';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SettingsResolver $settingsResolver,
|
|
||||||
private readonly WorkspaceEntitlementResolver $workspaceEntitlementResolver,
|
private readonly WorkspaceEntitlementResolver $workspaceEntitlementResolver,
|
||||||
|
private readonly WorkspaceSubscriptionResolver $workspaceSubscriptionResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -132,46 +133,37 @@ public function summary(Workspace $workspace): array
|
|||||||
*/
|
*/
|
||||||
public function resolve(Workspace $workspace): array
|
public function resolve(Workspace $workspace): array
|
||||||
{
|
{
|
||||||
$stateSetting = $this->settingsResolver->resolveDetailed(
|
$subscriptionSummary = $this->workspaceSubscriptionResolver->summary($workspace);
|
||||||
workspace: $workspace,
|
|
||||||
domain: self::SETTING_DOMAIN,
|
|
||||||
key: self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
|
||||||
);
|
|
||||||
|
|
||||||
$rawState = is_string($stateSetting['value'] ?? null)
|
$state = (string) $subscriptionSummary['derived_lifecycle_state'];
|
||||||
? strtolower(trim((string) $stateSetting['value']))
|
$source = (string) $subscriptionSummary['source'];
|
||||||
: null;
|
|
||||||
|
|
||||||
$state = in_array($rawState, self::stateIds(), true)
|
|
||||||
? $rawState
|
|
||||||
: self::STATE_ACTIVE_PAID;
|
|
||||||
|
|
||||||
$source = ($stateSetting['source'] ?? null) === 'workspace_override' && $rawState !== null
|
|
||||||
? self::SOURCE_WORKSPACE_SETTING
|
|
||||||
: self::SOURCE_DEFAULT_ACTIVE_PAID;
|
|
||||||
|
|
||||||
$rationale = $this->settingsResolver->resolveValue(
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: self::SETTING_DOMAIN,
|
|
||||||
key: self::SETTING_COMMERCIAL_LIFECYCLE_REASON,
|
|
||||||
);
|
|
||||||
|
|
||||||
$labels = self::stateLabels();
|
$labels = self::stateLabels();
|
||||||
$descriptions = self::stateDescriptions();
|
$descriptions = self::stateDescriptions();
|
||||||
$lastChanged = $this->lastChangedMetadata($workspace);
|
$lastChanged = $this->lastChangedMetadata($workspace, $source);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'state' => $state,
|
'state' => $state,
|
||||||
'state_label' => $labels[$state],
|
'state_label' => $labels[$state],
|
||||||
'source' => $source,
|
'source' => $source,
|
||||||
'source_label' => $source === self::SOURCE_WORKSPACE_SETTING
|
'source_label' => match ($source) {
|
||||||
? 'workspace setting'
|
self::SOURCE_WORKSPACE_SUBSCRIPTION => 'workspace subscription',
|
||||||
: 'default active paid',
|
self::SOURCE_WORKSPACE_SETTING => 'workspace setting',
|
||||||
'rationale' => is_string($rationale) && trim($rationale) !== '' ? trim($rationale) : null,
|
default => 'default active paid',
|
||||||
|
},
|
||||||
|
'rationale' => $subscriptionSummary['status_reason'] ?? null,
|
||||||
'description' => $descriptions[$state],
|
'description' => $descriptions[$state],
|
||||||
'last_changed_at' => $lastChanged['last_changed_at'],
|
'last_changed_at' => $lastChanged['last_changed_at'],
|
||||||
'last_changed_by' => $lastChanged['last_changed_by'],
|
'last_changed_by' => $lastChanged['last_changed_by'],
|
||||||
|
'subscription_present' => (bool) ($subscriptionSummary['subscription_present'] ?? false),
|
||||||
|
'fallback_status' => (bool) ($subscriptionSummary['fallback_status'] ?? true),
|
||||||
|
'subscription_state' => $subscriptionSummary['state'] ?? null,
|
||||||
|
'subscription_state_label' => $subscriptionSummary['label'] ?? null,
|
||||||
|
'subscription_billing_reference' => $subscriptionSummary['billing_reference'] ?? null,
|
||||||
|
'subscription_key_date_label' => $subscriptionSummary['key_date_label'] ?? null,
|
||||||
|
'subscription_key_date' => $subscriptionSummary['key_date'] ?? null,
|
||||||
|
'subscription_needs_review' => (bool) ($subscriptionSummary['needs_review'] ?? false),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,7 +223,7 @@ private function managedTenantActivationDecision(Workspace $workspace, array $li
|
|||||||
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
||||||
outcome: self::OUTCOME_BLOCK,
|
outcome: self::OUTCOME_BLOCK,
|
||||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
||||||
message: 'New managed-tenant activation is frozen while this workspace is in grace.',
|
message: $this->lifecycleMessage($lifecycle, 'New managed-tenant activation is frozen while this workspace is in grace.'),
|
||||||
substrateDecision: $substrateDecision,
|
substrateDecision: $substrateDecision,
|
||||||
),
|
),
|
||||||
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
|
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
|
||||||
@ -239,7 +231,7 @@ private function managedTenantActivationDecision(Workspace $workspace, array $li
|
|||||||
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
||||||
outcome: self::OUTCOME_BLOCK,
|
outcome: self::OUTCOME_BLOCK,
|
||||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
||||||
message: 'This workspace is suspended / read-only. New managed-tenant activation is blocked, but existing review and evidence history remains available.',
|
message: $this->lifecycleMessage($lifecycle, 'This workspace is suspended / read-only. New managed-tenant activation is blocked, but existing review and evidence history remains available.'),
|
||||||
substrateDecision: $substrateDecision,
|
substrateDecision: $substrateDecision,
|
||||||
),
|
),
|
||||||
default => $this->decision(
|
default => $this->decision(
|
||||||
@ -247,7 +239,7 @@ private function managedTenantActivationDecision(Workspace $workspace, array $li
|
|||||||
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
||||||
outcome: self::OUTCOME_ALLOW,
|
outcome: self::OUTCOME_ALLOW,
|
||||||
reasonFamily: null,
|
reasonFamily: null,
|
||||||
message: 'Managed-tenant activation is available for this workspace commercial state.',
|
message: $this->lifecycleMessage($lifecycle, 'Managed-tenant activation is available for this workspace commercial state.'),
|
||||||
substrateDecision: $substrateDecision,
|
substrateDecision: $substrateDecision,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@ -281,7 +273,7 @@ private function reviewPackStartDecision(Workspace $workspace, array $lifecycle)
|
|||||||
actionKey: self::ACTION_REVIEW_PACK_START,
|
actionKey: self::ACTION_REVIEW_PACK_START,
|
||||||
outcome: self::OUTCOME_WARN,
|
outcome: self::OUTCOME_WARN,
|
||||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
||||||
message: 'Workspace is in grace. Review-pack starts remain available, but managed-tenant expansion is frozen.',
|
message: $this->lifecycleMessage($lifecycle, 'Workspace is in grace. Review-pack starts remain available, but managed-tenant expansion is frozen.'),
|
||||||
substrateDecision: $substrateDecision,
|
substrateDecision: $substrateDecision,
|
||||||
),
|
),
|
||||||
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
|
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
|
||||||
@ -289,7 +281,7 @@ private function reviewPackStartDecision(Workspace $workspace, array $lifecycle)
|
|||||||
actionKey: self::ACTION_REVIEW_PACK_START,
|
actionKey: self::ACTION_REVIEW_PACK_START,
|
||||||
outcome: self::OUTCOME_BLOCK,
|
outcome: self::OUTCOME_BLOCK,
|
||||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
||||||
message: 'This workspace is suspended / read-only. New review-pack starts are blocked, but existing review packs, evidence, and review history remain available.',
|
message: $this->lifecycleMessage($lifecycle, 'This workspace is suspended / read-only. New review-pack starts are blocked, but existing review packs, evidence, and review history remain available.'),
|
||||||
substrateDecision: $substrateDecision,
|
substrateDecision: $substrateDecision,
|
||||||
),
|
),
|
||||||
default => $this->decision(
|
default => $this->decision(
|
||||||
@ -297,7 +289,7 @@ private function reviewPackStartDecision(Workspace $workspace, array $lifecycle)
|
|||||||
actionKey: self::ACTION_REVIEW_PACK_START,
|
actionKey: self::ACTION_REVIEW_PACK_START,
|
||||||
outcome: self::OUTCOME_ALLOW,
|
outcome: self::OUTCOME_ALLOW,
|
||||||
reasonFamily: null,
|
reasonFamily: null,
|
||||||
message: 'Review-pack starts are available for this workspace commercial state.',
|
message: $this->lifecycleMessage($lifecycle, 'Review-pack starts are available for this workspace commercial state.'),
|
||||||
substrateDecision: $substrateDecision,
|
substrateDecision: $substrateDecision,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@ -315,7 +307,7 @@ private function readOnlyDecision(string $actionKey, array $lifecycle): array
|
|||||||
actionKey: $actionKey,
|
actionKey: $actionKey,
|
||||||
outcome: self::OUTCOME_ALLOW_READ_ONLY,
|
outcome: self::OUTCOME_ALLOW_READ_ONLY,
|
||||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
||||||
message: 'Suspended / read-only workspaces keep existing review, evidence, and generated-pack consumption available under current RBAC.',
|
message: $this->lifecycleMessage($lifecycle, 'Suspended / read-only workspaces keep existing review, evidence, and generated-pack consumption available under current RBAC.'),
|
||||||
substrateDecision: null,
|
substrateDecision: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -325,11 +317,29 @@ private function readOnlyDecision(string $actionKey, array $lifecycle): array
|
|||||||
actionKey: $actionKey,
|
actionKey: $actionKey,
|
||||||
outcome: self::OUTCOME_ALLOW,
|
outcome: self::OUTCOME_ALLOW,
|
||||||
reasonFamily: null,
|
reasonFamily: null,
|
||||||
message: 'Read-only history remains available under current RBAC.',
|
message: $this->lifecycleMessage($lifecycle, 'Read-only history remains available under current RBAC.'),
|
||||||
substrateDecision: null,
|
substrateDecision: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $lifecycle
|
||||||
|
*/
|
||||||
|
private function lifecycleMessage(array $lifecycle, string $message): string
|
||||||
|
{
|
||||||
|
return sprintf('%s Commercial source: %s.', $message, $this->commercialSourceDescriptor($lifecycle));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $lifecycle
|
||||||
|
*/
|
||||||
|
private function commercialSourceDescriptor(array $lifecycle): string
|
||||||
|
{
|
||||||
|
return ($lifecycle['source'] ?? null) === self::SOURCE_WORKSPACE_SUBSCRIPTION
|
||||||
|
? 'subscription-backed'
|
||||||
|
: 'fallback-backed';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $lifecycle
|
* @param array<string, mixed> $lifecycle
|
||||||
* @param array<string, mixed>|null $substrateDecision
|
* @param array<string, mixed>|null $substrateDecision
|
||||||
@ -365,8 +375,34 @@ private function decision(
|
|||||||
/**
|
/**
|
||||||
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
|
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
|
||||||
*/
|
*/
|
||||||
private function lastChangedMetadata(Workspace $workspace): array
|
private function lastChangedMetadata(Workspace $workspace, string $source): array
|
||||||
{
|
{
|
||||||
|
if ($source === self::SOURCE_WORKSPACE_SUBSCRIPTION) {
|
||||||
|
$audit = AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('action', AuditActionId::WorkspaceSubscriptionUpdated->value)
|
||||||
|
->where('resource_type', 'workspace_subscription')
|
||||||
|
->latest('recorded_at')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($audit instanceof AuditLog) {
|
||||||
|
return [
|
||||||
|
'last_changed_at' => $audit->recorded_at,
|
||||||
|
'last_changed_by' => $audit->actorDisplayLabel(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace->loadMissing('subscription');
|
||||||
|
|
||||||
|
if ($workspace->subscription !== null) {
|
||||||
|
return [
|
||||||
|
'last_changed_at' => $workspace->subscription->updated_at,
|
||||||
|
'last_changed_by' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$audit = AuditLog::query()
|
$audit = AuditLog::query()
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
->where('action', AuditActionId::WorkspaceSettingUpdated->value)
|
->where('action', AuditActionId::WorkspaceSettingUpdated->value)
|
||||||
|
|||||||
@ -0,0 +1,205 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Entitlements;
|
||||||
|
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceSubscription;
|
||||||
|
use App\Services\Settings\SettingsResolver;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
final class WorkspaceSubscriptionResolver
|
||||||
|
{
|
||||||
|
public const SOURCE_WORKSPACE_SUBSCRIPTION = 'workspace_subscription';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function stateLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
WorkspaceSubscription::STATE_TRIAL => 'Trial',
|
||||||
|
WorkspaceSubscription::STATE_ACTIVE => 'Active',
|
||||||
|
WorkspaceSubscription::STATE_PAST_DUE => 'Past due',
|
||||||
|
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END => 'Cancellation pending',
|
||||||
|
WorkspaceSubscription::STATE_ENDED => 'Ended',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly SettingsResolver $settingsResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* subscription_present: bool,
|
||||||
|
* state: string|null,
|
||||||
|
* label: string|null,
|
||||||
|
* billing_reference: string|null,
|
||||||
|
* status_reason: string|null,
|
||||||
|
* key_date_label: string|null,
|
||||||
|
* key_date: CarbonInterface|null,
|
||||||
|
* needs_review: bool,
|
||||||
|
* source: string,
|
||||||
|
* fallback_status: bool,
|
||||||
|
* derived_lifecycle_state: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function summary(Workspace $workspace): array
|
||||||
|
{
|
||||||
|
$workspace->loadMissing('subscription');
|
||||||
|
|
||||||
|
$subscription = $workspace->subscription;
|
||||||
|
|
||||||
|
if ($subscription instanceof WorkspaceSubscription) {
|
||||||
|
return $this->subscriptionSummary($workspace, $subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->fallbackSummary($workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* subscription_present: bool,
|
||||||
|
* state: string,
|
||||||
|
* label: string,
|
||||||
|
* billing_reference: string|null,
|
||||||
|
* status_reason: string,
|
||||||
|
* key_date_label: string|null,
|
||||||
|
* key_date: CarbonInterface|null,
|
||||||
|
* needs_review: bool,
|
||||||
|
* source: string,
|
||||||
|
* fallback_status: bool,
|
||||||
|
* derived_lifecycle_state: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function subscriptionSummary(Workspace $workspace, WorkspaceSubscription $subscription): array
|
||||||
|
{
|
||||||
|
$state = in_array($subscription->state, WorkspaceSubscription::stateIds(), true)
|
||||||
|
? $subscription->state
|
||||||
|
: WorkspaceSubscription::STATE_ACTIVE;
|
||||||
|
|
||||||
|
$keyDate = $this->keyDateForSubscription($subscription, $state);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'subscription_present' => true,
|
||||||
|
'state' => $state,
|
||||||
|
'label' => self::stateLabels()[$state],
|
||||||
|
'billing_reference' => $subscription->billing_reference,
|
||||||
|
'status_reason' => $subscription->status_reason,
|
||||||
|
'key_date_label' => $this->keyDateLabel($state),
|
||||||
|
'key_date' => $keyDate,
|
||||||
|
'needs_review' => $this->needsReview($state, $keyDate),
|
||||||
|
'source' => self::SOURCE_WORKSPACE_SUBSCRIPTION,
|
||||||
|
'fallback_status' => false,
|
||||||
|
'derived_lifecycle_state' => $this->derivedLifecycleState($state),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* subscription_present: bool,
|
||||||
|
* state: null,
|
||||||
|
* label: null,
|
||||||
|
* billing_reference: null,
|
||||||
|
* status_reason: string|null,
|
||||||
|
* key_date_label: null,
|
||||||
|
* key_date: null,
|
||||||
|
* needs_review: bool,
|
||||||
|
* source: string,
|
||||||
|
* fallback_status: bool,
|
||||||
|
* derived_lifecycle_state: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function fallbackSummary(Workspace $workspace): array
|
||||||
|
{
|
||||||
|
$stateSetting = $this->settingsResolver->resolveDetailed(
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
|
||||||
|
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
||||||
|
);
|
||||||
|
|
||||||
|
$rawState = is_string($stateSetting['value'] ?? null)
|
||||||
|
? strtolower(trim((string) $stateSetting['value']))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$derivedLifecycleState = in_array($rawState, WorkspaceCommercialLifecycleResolver::stateIds(), true)
|
||||||
|
? $rawState
|
||||||
|
: WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID;
|
||||||
|
|
||||||
|
$source = ($stateSetting['source'] ?? null) === 'workspace_override' && $rawState !== null
|
||||||
|
? WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING
|
||||||
|
: WorkspaceCommercialLifecycleResolver::SOURCE_DEFAULT_ACTIVE_PAID;
|
||||||
|
|
||||||
|
$statusReason = $this->settingsResolver->resolveValue(
|
||||||
|
workspace: $workspace,
|
||||||
|
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
|
||||||
|
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'subscription_present' => false,
|
||||||
|
'state' => null,
|
||||||
|
'label' => null,
|
||||||
|
'billing_reference' => null,
|
||||||
|
'status_reason' => is_string($statusReason) && trim($statusReason) !== '' ? trim($statusReason) : null,
|
||||||
|
'key_date_label' => null,
|
||||||
|
'key_date' => null,
|
||||||
|
'needs_review' => false,
|
||||||
|
'source' => $source,
|
||||||
|
'fallback_status' => true,
|
||||||
|
'derived_lifecycle_state' => $derivedLifecycleState,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function derivedLifecycleState(string $state): string
|
||||||
|
{
|
||||||
|
return match ($state) {
|
||||||
|
WorkspaceSubscription::STATE_TRIAL => WorkspaceCommercialLifecycleResolver::STATE_TRIAL,
|
||||||
|
WorkspaceSubscription::STATE_ACTIVE => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
|
||||||
|
WorkspaceSubscription::STATE_PAST_DUE => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
|
||||||
|
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
|
||||||
|
WorkspaceSubscription::STATE_ENDED => WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
|
||||||
|
default => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function keyDateLabel(string $state): ?string
|
||||||
|
{
|
||||||
|
return match ($state) {
|
||||||
|
WorkspaceSubscription::STATE_TRIAL => 'Trial ends',
|
||||||
|
WorkspaceSubscription::STATE_ACTIVE,
|
||||||
|
WorkspaceSubscription::STATE_PAST_DUE,
|
||||||
|
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||||
|
WorkspaceSubscription::STATE_ENDED => 'Current period ends',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function keyDateForSubscription(WorkspaceSubscription $subscription, string $state): ?CarbonInterface
|
||||||
|
{
|
||||||
|
return match ($state) {
|
||||||
|
WorkspaceSubscription::STATE_TRIAL => $subscription->trial_ends_at,
|
||||||
|
WorkspaceSubscription::STATE_ACTIVE,
|
||||||
|
WorkspaceSubscription::STATE_PAST_DUE,
|
||||||
|
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||||
|
WorkspaceSubscription::STATE_ENDED => $subscription->current_period_ends_at,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function needsReview(string $state, ?CarbonInterface $keyDate): bool
|
||||||
|
{
|
||||||
|
if (! in_array($state, [WorkspaceSubscription::STATE_TRIAL, WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $keyDate instanceof CarbonInterface && $keyDate->isPast();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,7 +43,7 @@ public function createBackupSet(
|
|||||||
$policies = Policy::query()
|
$policies = Policy::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->whereIn('id', $policyIds)
|
->whereIn('id', $policyIds)
|
||||||
->whereNull('ignored_at')
|
->currentBackupEligible()
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags, $includeFoundations) {
|
$backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags, $includeFoundations) {
|
||||||
@ -184,7 +184,7 @@ public function addPoliciesToSet(
|
|||||||
$policies = Policy::query()
|
$policies = Policy::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->whereIn('id', $policyIds)
|
->whereIn('id', $policyIds)
|
||||||
->whereNull('ignored_at')
|
->currentBackupEligible()
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$metadata = $backupSet->metadata ?? [];
|
$metadata = $backupSet->metadata ?? [];
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Services\Graph\GraphLogger;
|
use App\Services\Graph\GraphLogger;
|
||||||
use App\Services\Providers\ProviderConnectionResolver;
|
use App\Services\Providers\ProviderConnectionResolver;
|
||||||
use App\Services\Providers\ProviderGateway;
|
use App\Services\Providers\ProviderGateway;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Providers\ProviderNextStepsRegistry;
|
use App\Support\Providers\ProviderNextStepsRegistry;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@ -25,6 +26,7 @@ public function __construct(
|
|||||||
private readonly ?ProviderConnectionResolver $providerConnections = null,
|
private readonly ?ProviderConnectionResolver $providerConnections = null,
|
||||||
private readonly ?ProviderGateway $providerGateway = null,
|
private readonly ?ProviderGateway $providerGateway = null,
|
||||||
private readonly ?ProviderNextStepsRegistry $nextStepsRegistry = null,
|
private readonly ?ProviderNextStepsRegistry $nextStepsRegistry = null,
|
||||||
|
private readonly ?AuditLogger $auditLogger = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -54,6 +56,8 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
|||||||
$types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []);
|
$types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []);
|
||||||
$synced = [];
|
$synced = [];
|
||||||
$failures = [];
|
$failures = [];
|
||||||
|
$successfulPolicyTypes = [];
|
||||||
|
$observedExternalIdsByPolicyType = [];
|
||||||
|
|
||||||
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
|
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
|
||||||
|
|
||||||
@ -110,6 +114,9 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$successfulPolicyTypes[$policyType] = true;
|
||||||
|
$observedExternalIdsByPolicyType[$policyType] ??= [];
|
||||||
|
|
||||||
foreach ($response->data as $policyData) {
|
foreach ($response->data as $policyData) {
|
||||||
$externalId = $policyData['id'] ?? $policyData['external_id'] ?? null;
|
$externalId = $policyData['id'] ?? $policyData['external_id'] ?? null;
|
||||||
|
|
||||||
@ -117,6 +124,8 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$externalId = (string) $externalId;
|
||||||
|
|
||||||
$canonicalPolicyType = $this->resolveCanonicalPolicyType($policyType, $policyData);
|
$canonicalPolicyType = $this->resolveCanonicalPolicyType($policyType, $policyData);
|
||||||
|
|
||||||
if ($canonicalPolicyType !== $policyType) {
|
if ($canonicalPolicyType !== $policyType) {
|
||||||
@ -127,52 +136,60 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
|||||||
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
|
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
|
||||||
|
|
||||||
if (is_string($odataType) && strtolower($odataType) === '#microsoft.graph.targetedmanagedappconfiguration') {
|
if (is_string($odataType) && strtolower($odataType) === '#microsoft.graph.targetedmanagedappconfiguration') {
|
||||||
Policy::query()
|
|
||||||
->where('tenant_id', $tenant->id)
|
|
||||||
->where('external_id', $externalId)
|
|
||||||
->where('policy_type', $policyType)
|
|
||||||
->whereNull('ignored_at')
|
|
||||||
->update(['ignored_at' => now()]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$observedExternalIdsByPolicyType[$policyType][] = $externalId;
|
||||||
$displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy';
|
$displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy';
|
||||||
$policyPlatform = $platform ?? ($policyData['platform'] ?? null);
|
$policyPlatform = $platform ?? ($policyData['platform'] ?? null);
|
||||||
|
|
||||||
$this->reclassifyEnrollmentConfigurationPoliciesIfNeeded(
|
$this->reclassifyEnrollmentConfigurationPoliciesIfNeeded(
|
||||||
tenantId: $tenant->id,
|
tenant: $tenant,
|
||||||
externalId: $externalId,
|
externalId: $externalId,
|
||||||
policyType: $policyType,
|
policyType: $policyType,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->reclassifyConfigurationPoliciesIfNeeded(
|
$this->reclassifyConfigurationPoliciesIfNeeded(
|
||||||
tenantId: $tenant->id,
|
tenant: $tenant,
|
||||||
externalId: $externalId,
|
externalId: $externalId,
|
||||||
policyType: $policyType,
|
policyType: $policyType,
|
||||||
);
|
);
|
||||||
|
|
||||||
$policy = Policy::updateOrCreate(
|
$policy = Policy::query()->firstOrNew([
|
||||||
[
|
'tenant_id' => $tenant->id,
|
||||||
'tenant_id' => $tenant->id,
|
'external_id' => $externalId,
|
||||||
'external_id' => $externalId,
|
'policy_type' => $policyType,
|
||||||
'policy_type' => $policyType,
|
]);
|
||||||
],
|
$wasProviderMissing = $policy->exists && $policy->missing_from_provider_at !== null;
|
||||||
[
|
|
||||||
'workspace_id' => $tenant->workspace_id,
|
$policy->forceFill([
|
||||||
'display_name' => $displayName,
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'platform' => $policyPlatform,
|
'display_name' => $displayName,
|
||||||
'last_synced_at' => now(),
|
'platform' => $policyPlatform,
|
||||||
'ignored_at' => null,
|
'last_synced_at' => now(),
|
||||||
'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']),
|
'missing_from_provider_at' => null,
|
||||||
]
|
'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']),
|
||||||
);
|
])->save();
|
||||||
|
|
||||||
|
if ($wasProviderMissing) {
|
||||||
|
$this->auditProviderPresenceTransition(
|
||||||
|
tenant: $tenant,
|
||||||
|
policy: $policy,
|
||||||
|
action: AuditActionId::PolicyProviderMissingCleared,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$synced[] = $policy->id;
|
$synced[] = $policy->id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->markProviderMissingPolicies(
|
||||||
|
tenant: $tenant,
|
||||||
|
policyTypes: array_keys($successfulPolicyTypes),
|
||||||
|
observedExternalIdsByPolicyType: $observedExternalIdsByPolicyType,
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'synced' => $synced,
|
'synced' => $synced,
|
||||||
'failures' => $failures,
|
'failures' => $failures,
|
||||||
@ -338,7 +355,7 @@ private function isEnrollmentNotificationItem(array $policyData): bool
|
|||||||
], true);
|
], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
|
private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(Tenant $tenant, string $externalId, string $policyType): void
|
||||||
{
|
{
|
||||||
$enrollmentTypes = [
|
$enrollmentTypes = [
|
||||||
'enrollmentRestriction',
|
'enrollmentRestriction',
|
||||||
@ -353,45 +370,54 @@ private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId
|
|||||||
}
|
}
|
||||||
|
|
||||||
$existingCorrect = Policy::query()
|
$existingCorrect = Policy::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('external_id', $externalId)
|
->where('external_id', $externalId)
|
||||||
->where('policy_type', $policyType)
|
->where('policy_type', $policyType)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existingCorrect) {
|
if ($existingCorrect) {
|
||||||
Policy::query()
|
$this->markSiblingPoliciesProviderMissing(
|
||||||
->where('tenant_id', $tenantId)
|
tenant: $tenant,
|
||||||
->where('external_id', $externalId)
|
externalId: $externalId,
|
||||||
->whereIn('policy_type', $enrollmentTypes)
|
policyTypes: $enrollmentTypes,
|
||||||
->where('policy_type', '!=', $policyType)
|
exceptPolicyType: $policyType,
|
||||||
->whereNull('ignored_at')
|
);
|
||||||
->update(['ignored_at' => now()]);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$existingWrong = Policy::query()
|
$existingWrong = Policy::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('external_id', $externalId)
|
->where('external_id', $externalId)
|
||||||
->whereIn('policy_type', $enrollmentTypes)
|
->whereIn('policy_type', $enrollmentTypes)
|
||||||
->where('policy_type', '!=', $policyType)
|
->where('policy_type', '!=', $policyType)
|
||||||
->whereNull('ignored_at')
|
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $existingWrong) {
|
if (! $existingWrong) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$wasProviderMissing = $existingWrong->missing_from_provider_at !== null;
|
||||||
|
|
||||||
$existingWrong->forceFill([
|
$existingWrong->forceFill([
|
||||||
'policy_type' => $policyType,
|
'policy_type' => $policyType,
|
||||||
|
'missing_from_provider_at' => null,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
if ($wasProviderMissing) {
|
||||||
|
$this->auditProviderPresenceTransition(
|
||||||
|
tenant: $tenant,
|
||||||
|
policy: $existingWrong,
|
||||||
|
action: AuditActionId::PolicyProviderMissingCleared,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
PolicyVersion::query()
|
PolicyVersion::query()
|
||||||
->where('policy_id', $existingWrong->id)
|
->where('policy_id', $existingWrong->id)
|
||||||
->update(['policy_type' => $policyType]);
|
->update(['policy_type' => $policyType]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reclassifyConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
|
private function reclassifyConfigurationPoliciesIfNeeded(Tenant $tenant, string $externalId, string $policyType): void
|
||||||
{
|
{
|
||||||
$configurationTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'];
|
$configurationTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'];
|
||||||
|
|
||||||
@ -400,44 +426,154 @@ private function reclassifyConfigurationPoliciesIfNeeded(int $tenantId, string $
|
|||||||
}
|
}
|
||||||
|
|
||||||
$existingCorrect = Policy::query()
|
$existingCorrect = Policy::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('external_id', $externalId)
|
->where('external_id', $externalId)
|
||||||
->where('policy_type', $policyType)
|
->where('policy_type', $policyType)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existingCorrect) {
|
if ($existingCorrect) {
|
||||||
Policy::query()
|
$this->markSiblingPoliciesProviderMissing(
|
||||||
->where('tenant_id', $tenantId)
|
tenant: $tenant,
|
||||||
->where('external_id', $externalId)
|
externalId: $externalId,
|
||||||
->whereIn('policy_type', $configurationTypes)
|
policyTypes: $configurationTypes,
|
||||||
->where('policy_type', '!=', $policyType)
|
exceptPolicyType: $policyType,
|
||||||
->whereNull('ignored_at')
|
);
|
||||||
->update(['ignored_at' => now()]);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$existingWrong = Policy::query()
|
$existingWrong = Policy::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('external_id', $externalId)
|
->where('external_id', $externalId)
|
||||||
->whereIn('policy_type', $configurationTypes)
|
->whereIn('policy_type', $configurationTypes)
|
||||||
->where('policy_type', '!=', $policyType)
|
->where('policy_type', '!=', $policyType)
|
||||||
->whereNull('ignored_at')
|
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $existingWrong) {
|
if (! $existingWrong) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$wasProviderMissing = $existingWrong->missing_from_provider_at !== null;
|
||||||
|
|
||||||
$existingWrong->forceFill([
|
$existingWrong->forceFill([
|
||||||
'policy_type' => $policyType,
|
'policy_type' => $policyType,
|
||||||
|
'missing_from_provider_at' => null,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
if ($wasProviderMissing) {
|
||||||
|
$this->auditProviderPresenceTransition(
|
||||||
|
tenant: $tenant,
|
||||||
|
policy: $existingWrong,
|
||||||
|
action: AuditActionId::PolicyProviderMissingCleared,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
PolicyVersion::query()
|
PolicyVersion::query()
|
||||||
->where('policy_id', $existingWrong->id)
|
->where('policy_id', $existingWrong->id)
|
||||||
->update(['policy_type' => $policyType]);
|
->update(['policy_type' => $policyType]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $policyTypes
|
||||||
|
*/
|
||||||
|
private function markSiblingPoliciesProviderMissing(Tenant $tenant, string $externalId, array $policyTypes, string $exceptPolicyType): void
|
||||||
|
{
|
||||||
|
$timestamp = now();
|
||||||
|
|
||||||
|
Policy::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('external_id', $externalId)
|
||||||
|
->whereIn('policy_type', $policyTypes)
|
||||||
|
->where('policy_type', '!=', $exceptPolicyType)
|
||||||
|
->whereNull('missing_from_provider_at')
|
||||||
|
->get()
|
||||||
|
->each(function (Policy $policy) use ($tenant, $timestamp): void {
|
||||||
|
$policy->forceFill(['missing_from_provider_at' => $timestamp])->save();
|
||||||
|
|
||||||
|
$this->auditProviderPresenceTransition(
|
||||||
|
tenant: $tenant,
|
||||||
|
policy: $policy,
|
||||||
|
action: AuditActionId::PolicyProviderMissingDetected,
|
||||||
|
transitionAt: $timestamp,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $policyTypes
|
||||||
|
* @param array<string, array<int, string>> $observedExternalIdsByPolicyType
|
||||||
|
*/
|
||||||
|
private function markProviderMissingPolicies(Tenant $tenant, array $policyTypes, array $observedExternalIdsByPolicyType): void
|
||||||
|
{
|
||||||
|
foreach ($policyTypes as $policyType) {
|
||||||
|
if (! is_string($policyType) || $policyType === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$observedExternalIds = array_values(array_unique(array_filter(
|
||||||
|
array_map('strval', $observedExternalIdsByPolicyType[$policyType] ?? []),
|
||||||
|
static fn (string $externalId): bool => $externalId !== '',
|
||||||
|
)));
|
||||||
|
|
||||||
|
$query = Policy::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('policy_type', $policyType)
|
||||||
|
->whereNull('missing_from_provider_at');
|
||||||
|
|
||||||
|
if ($observedExternalIds !== []) {
|
||||||
|
$query->whereNotIn('external_id', $observedExternalIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamp = now();
|
||||||
|
|
||||||
|
$query->get()
|
||||||
|
->each(function (Policy $policy) use ($tenant, $timestamp): void {
|
||||||
|
$policy->forceFill(['missing_from_provider_at' => $timestamp])->save();
|
||||||
|
|
||||||
|
$this->auditProviderPresenceTransition(
|
||||||
|
tenant: $tenant,
|
||||||
|
policy: $policy,
|
||||||
|
action: AuditActionId::PolicyProviderMissingDetected,
|
||||||
|
transitionAt: $timestamp,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditProviderPresenceTransition(
|
||||||
|
Tenant $tenant,
|
||||||
|
Policy $policy,
|
||||||
|
AuditActionId $action,
|
||||||
|
mixed $transitionAt = null,
|
||||||
|
): void {
|
||||||
|
$transitionAt ??= now();
|
||||||
|
|
||||||
|
$this->auditLogger()->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: $action,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'policy_id' => (int) $policy->getKey(),
|
||||||
|
'external_id' => (string) $policy->external_id,
|
||||||
|
'policy_type' => (string) $policy->policy_type,
|
||||||
|
'transition_at' => method_exists($transitionAt, 'toIso8601String')
|
||||||
|
? $transitionAt->toIso8601String()
|
||||||
|
: (string) $transitionAt,
|
||||||
|
'source' => 'policy_sync',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
resourceType: 'policy',
|
||||||
|
resourceId: (string) $policy->getKey(),
|
||||||
|
targetLabel: (string) $policy->display_name,
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditLogger(): AuditLogger
|
||||||
|
{
|
||||||
|
return $this->auditLogger ?? app(AuditLogger::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-fetch a single policy from Graph and update local metadata.
|
* Re-fetch a single policy from Graph and update local metadata.
|
||||||
*/
|
*/
|
||||||
@ -506,13 +642,23 @@ public function syncPolicy(Tenant $tenant, Policy $policy): void
|
|||||||
|
|
||||||
$displayName = $payload['displayName'] ?? $payload['name'] ?? $policy->display_name;
|
$displayName = $payload['displayName'] ?? $payload['name'] ?? $policy->display_name;
|
||||||
$platform = $payload['platform'] ?? $policy->platform;
|
$platform = $payload['platform'] ?? $policy->platform;
|
||||||
|
$wasProviderMissing = $policy->missing_from_provider_at !== null;
|
||||||
|
|
||||||
$policy->forceFill([
|
$policy->forceFill([
|
||||||
'display_name' => $displayName,
|
'display_name' => $displayName,
|
||||||
'platform' => $platform,
|
'platform' => $platform,
|
||||||
'last_synced_at' => now(),
|
'last_synced_at' => now(),
|
||||||
|
'missing_from_provider_at' => null,
|
||||||
'metadata' => Arr::except($payload, ['id', 'external_id', 'displayName', 'name', 'platform']),
|
'metadata' => Arr::except($payload, ['id', 'external_id', 'displayName', 'name', 'platform']),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
if ($wasProviderMissing) {
|
||||||
|
$this->auditProviderPresenceTransition(
|
||||||
|
tenant: $tenant,
|
||||||
|
policy: $policy,
|
||||||
|
action: AuditActionId::PolicyProviderMissingCleared,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -86,6 +86,19 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($context->workspaceRequiredCapability !== null) {
|
||||||
|
$checks['capability'] = $this->initiatorHasRequiredWorkspaceCapability($context) ? 'passed' : 'failed';
|
||||||
|
|
||||||
|
if ($checks['capability'] === 'failed') {
|
||||||
|
return QueuedExecutionLegitimacyDecision::deny(
|
||||||
|
$context,
|
||||||
|
$checks,
|
||||||
|
ExecutionDenialReasonCode::MissingCapability,
|
||||||
|
['required_capability' => $context->workspaceRequiredCapability],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (! $this->isSystemAuthorityAllowed($context->operationType)) {
|
if (! $this->isSystemAuthorityAllowed($context->operationType)) {
|
||||||
$checks['execution_prerequisites'] = 'failed';
|
$checks['execution_prerequisites'] = 'failed';
|
||||||
@ -151,6 +164,9 @@ public function buildContext(OperationRun $run): QueuedExecutionContext
|
|||||||
requiredCapability: is_string($context['required_capability'] ?? null)
|
requiredCapability: is_string($context['required_capability'] ?? null)
|
||||||
? $context['required_capability']
|
? $context['required_capability']
|
||||||
: $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType($operationType),
|
: $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType($operationType),
|
||||||
|
workspaceRequiredCapability: is_string($context['workspace_required_capability'] ?? null)
|
||||||
|
? $context['workspace_required_capability']
|
||||||
|
: null,
|
||||||
providerConnectionId: $providerConnectionId,
|
providerConnectionId: $providerConnectionId,
|
||||||
targetScope: [
|
targetScope: [
|
||||||
'workspace_id' => $workspaceId,
|
'workspace_id' => $workspaceId,
|
||||||
@ -259,6 +275,29 @@ private function initiatorHasRequiredCapability(QueuedExecutionContext $context)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function initiatorHasRequiredWorkspaceCapability(QueuedExecutionContext $context): bool
|
||||||
|
{
|
||||||
|
if (! $context->initiator instanceof User || ! is_string($context->workspaceRequiredCapability) || $context->workspaceRequiredCapability === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($context->workspaceId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = $context->run->tenant?->workspace ?? $context->run->workspace()->first();
|
||||||
|
|
||||||
|
if ($workspace === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->workspaceCapabilityResolver->can(
|
||||||
|
$context->initiator,
|
||||||
|
$workspace,
|
||||||
|
$context->workspaceRequiredCapability,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
@ -270,7 +309,7 @@ private function prerequisiteClassesFor(string $operationType, ?int $providerCon
|
|||||||
$prerequisites[] = 'provider_connection';
|
$prerequisites[] = 'provider_connection';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_starts_with($operationType, 'restore.')) {
|
if (str_starts_with($operationType, 'restore.') || $operationType === 'promotion.execute') {
|
||||||
$prerequisites[] = 'write_gate';
|
$prerequisites[] = 'write_gate';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,6 +318,10 @@ private function prerequisiteClassesFor(string $operationType, ?int $providerCon
|
|||||||
|
|
||||||
private function questionForContext(QueuedExecutionContext $context): TenantOperabilityQuestion
|
private function questionForContext(QueuedExecutionContext $context): TenantOperabilityQuestion
|
||||||
{
|
{
|
||||||
|
if ($context->operationType === 'promotion.execute') {
|
||||||
|
return TenantOperabilityQuestion::RestoreEligibility;
|
||||||
|
}
|
||||||
|
|
||||||
if ($context->providerConnectionId !== null || in_array($context->operationType, ['provider.connection.check', 'compliance.snapshot', 'provider.compliance.snapshot'], true)) {
|
if ($context->providerConnectionId !== null || in_array($context->operationType, ['provider.connection.check', 'compliance.snapshot', 'provider.compliance.snapshot'], true)) {
|
||||||
return TenantOperabilityQuestion::VerificationReadinessEligibility;
|
return TenantOperabilityQuestion::VerificationReadinessEligibility;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\PortfolioCompare;
|
||||||
|
|
||||||
|
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Providers\ProviderOperationStartResult;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||||
|
use App\Support\OperationalControls\OperationalControlEvaluator;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
||||||
|
use App\Support\PortfolioCompare\CrossTenantPromotionExecutionPlanner;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
|
final class CrossTenantPromotionExecutionService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CrossTenantPromotionExecutionPlanner $planner,
|
||||||
|
private readonly OperationRunService $operationRuns,
|
||||||
|
private readonly WorkspaceAuditLogger $auditLogger,
|
||||||
|
private readonly OperationalControlEvaluator $operationalControls,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $preview
|
||||||
|
* @param array<string, mixed> $preflight
|
||||||
|
*/
|
||||||
|
public function start(
|
||||||
|
CrossTenantCompareSelection $selection,
|
||||||
|
array $preview,
|
||||||
|
array $preflight,
|
||||||
|
User $actor,
|
||||||
|
): ProviderOperationStartResult {
|
||||||
|
$workspace = $selection->targetTenant->workspace;
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
throw new \RuntimeException('Promotion execution requires a workspace context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$decision = $this->operationalControls->evaluate('promotion.execute', $workspace);
|
||||||
|
|
||||||
|
if ($decision->isPaused()) {
|
||||||
|
$this->auditLogger->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' => (int) $actor->getKey(),
|
||||||
|
'source_tenant_id' => (int) $selection->sourceTenant->getKey(),
|
||||||
|
'target_tenant_id' => (int) $selection->targetTenant->getKey(),
|
||||||
|
'requested_scope' => 'promotion.execute',
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||||
|
],
|
||||||
|
actor: $actor,
|
||||||
|
status: 'blocked',
|
||||||
|
resourceType: 'operational_control',
|
||||||
|
resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null,
|
||||||
|
targetLabel: 'Promotion execution',
|
||||||
|
summary: 'Promotion execution blocked by operational control',
|
||||||
|
tenant: $selection->targetTenant,
|
||||||
|
);
|
||||||
|
|
||||||
|
throw OperationalControlBlockedException::forDecision($decision, 'Promotion execution');
|
||||||
|
}
|
||||||
|
|
||||||
|
$plan = $this->planner->build($preview, $preflight);
|
||||||
|
$providerConnection = $this->defaultProviderConnection((int) $selection->targetTenant->getKey());
|
||||||
|
$now = CarbonImmutable::now();
|
||||||
|
|
||||||
|
$identity = array_replace($plan['identity'], [
|
||||||
|
'provider_connection_id' => $providerConnection?->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$context = [
|
||||||
|
'operation_type' => 'promotion.execute',
|
||||||
|
'source_tenant_id' => (int) $selection->sourceTenant->getKey(),
|
||||||
|
'source_tenant_name' => (string) $selection->sourceTenant->name,
|
||||||
|
'target_tenant_id' => (int) $selection->targetTenant->getKey(),
|
||||||
|
'target_tenant_name' => (string) $selection->targetTenant->name,
|
||||||
|
'provider_connection_id' => $providerConnection instanceof ProviderConnection ? (int) $providerConnection->getKey() : null,
|
||||||
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
|
'workspace_required_capability' => Capabilities::WORKSPACE_BASELINES_MANAGE,
|
||||||
|
'target_scope' => [
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $selection->targetTenant->getKey(),
|
||||||
|
'provider_connection_id' => $providerConnection instanceof ProviderConnection ? (int) $providerConnection->getKey() : null,
|
||||||
|
'entra_tenant_id' => $providerConnection instanceof ProviderConnection
|
||||||
|
? (string) $providerConnection->entra_tenant_id
|
||||||
|
: (string) ($selection->targetTenant->tenant_id ?? $selection->targetTenant->external_id ?? $selection->targetTenant->getKey()),
|
||||||
|
],
|
||||||
|
'promotion_execution' => [
|
||||||
|
'queued_at' => $now->toIso8601String(),
|
||||||
|
'queued_by_user_id' => (int) $actor->getKey(),
|
||||||
|
'plan' => $plan,
|
||||||
|
],
|
||||||
|
'selection' => $plan['selection'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$run = $this->operationRuns->ensureRunWithIdentity(
|
||||||
|
tenant: $selection->targetTenant,
|
||||||
|
type: 'promotion.execute',
|
||||||
|
identityInputs: $identity,
|
||||||
|
context: $context,
|
||||||
|
initiator: $actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $run->wasRecentlyCreated) {
|
||||||
|
return ProviderOperationStartResult::deduped($run);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->operationRuns->updateRun($run, OperationRunStatus::Queued->value, summaryCounts: [
|
||||||
|
'total' => (int) $plan['summary']['ready'],
|
||||||
|
'processed' => 0,
|
||||||
|
'succeeded' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'created' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->operationRuns->dispatchOrFail(
|
||||||
|
$run,
|
||||||
|
fn (OperationRun $operationRun): mixed => CrossTenantPromotionExecutionJob::dispatch($operationRun),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->auditLogger->logCrossTenantPromotionExecutionQueued(
|
||||||
|
workspace: $workspace,
|
||||||
|
sourceTenant: $selection->sourceTenant,
|
||||||
|
targetTenant: $selection->targetTenant,
|
||||||
|
operationRun: $run->fresh() ?? $run,
|
||||||
|
plan: $plan,
|
||||||
|
actor: $actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ProviderOperationStartResult::started($run->fresh() ?? $run, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultProviderConnection(int $tenantId): ?ProviderConnection
|
||||||
|
{
|
||||||
|
return ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('provider', 'microsoft')
|
||||||
|
->where('is_default', true)
|
||||||
|
->orderBy('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,10 @@
|
|||||||
|
|
||||||
class ReviewPackService
|
class ReviewPackService
|
||||||
{
|
{
|
||||||
|
public const string REVIEW_DERIVED_DELIVERY_CONTRACT = 'auditor_ready_executive_export.v1';
|
||||||
|
|
||||||
|
public const string EXECUTIVE_ENTRYPOINT_FILENAME = 'executive-summary.md';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private OperationRunService $operationRunService,
|
private OperationRunService $operationRunService,
|
||||||
private EvidenceSnapshotResolver $snapshotResolver,
|
private EvidenceSnapshotResolver $snapshotResolver,
|
||||||
@ -193,6 +197,11 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
|
|||||||
'review_status' => (string) $review->status,
|
'review_status' => (string) $review->status,
|
||||||
'review_completeness_state' => (string) $review->completeness_state,
|
'review_completeness_state' => (string) $review->completeness_state,
|
||||||
'section_count' => $review->sections->count(),
|
'section_count' => $review->sections->count(),
|
||||||
|
'delivery_bundle' => [
|
||||||
|
'contract' => self::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
||||||
|
'executive_entrypoint_file' => self::EXECUTIVE_ENTRYPOINT_FILENAME,
|
||||||
|
'appendix_files' => ['metadata.json', 'summary.json', 'sections.json'],
|
||||||
|
],
|
||||||
'finding_outcomes' => is_array($review->summary['finding_outcomes'] ?? null)
|
'finding_outcomes' => is_array($review->summary['finding_outcomes'] ?? null)
|
||||||
? $review->summary['finding_outcomes']
|
? $review->summary['finding_outcomes']
|
||||||
: [],
|
: [],
|
||||||
@ -376,6 +385,7 @@ public function computeFingerprintForReview(TenantReview $review, array $options
|
|||||||
'tenant_review_id' => (int) $review->getKey(),
|
'tenant_review_id' => (int) $review->getKey(),
|
||||||
'review_fingerprint' => (string) $review->fingerprint,
|
'review_fingerprint' => (string) $review->fingerprint,
|
||||||
'review_status' => (string) $review->status,
|
'review_status' => (string) $review->status,
|
||||||
|
'delivery_contract' => self::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
||||||
'include_pii' => (bool) ($options['include_pii'] ?? true),
|
'include_pii' => (bool) ($options['include_pii'] ?? true),
|
||||||
'include_operations' => (bool) ($options['include_operations'] ?? true),
|
'include_operations' => (bool) ($options['include_operations'] ?? true),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
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\Models\WorkspaceSubscription;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||||
@ -19,6 +20,7 @@
|
|||||||
use App\Support\Settings\SettingDefinition;
|
use App\Support\Settings\SettingDefinition;
|
||||||
use App\Support\Settings\SettingsRegistry;
|
use App\Support\Settings\SettingsRegistry;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@ -135,6 +137,108 @@ public function updateWorkspaceCommercialLifecycle(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
public function updateWorkspaceSubscription(
|
||||||
|
PlatformUser $actor,
|
||||||
|
Workspace $workspace,
|
||||||
|
array $attributes,
|
||||||
|
): WorkspaceSubscription {
|
||||||
|
$this->authorizeCommercialLifecycleManage($actor);
|
||||||
|
|
||||||
|
$validator = Validator::make($attributes, [
|
||||||
|
'state' => ['required', 'string', 'in:'.implode(',', WorkspaceSubscription::stateIds())],
|
||||||
|
'billing_reference' => ['nullable', 'string', 'max:191'],
|
||||||
|
'trial_ends_at' => ['nullable', 'date'],
|
||||||
|
'current_period_starts_at' => ['nullable', 'date'],
|
||||||
|
'current_period_ends_at' => ['nullable', 'date'],
|
||||||
|
'status_reason' => ['required', 'string', 'max:500'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
throw ValidationException::withMessages($validator->errors()->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $validator->validated();
|
||||||
|
$state = (string) $validated['state'];
|
||||||
|
|
||||||
|
if ($state === WorkspaceSubscription::STATE_TRIAL && blank($validated['trial_ends_at'] ?? null)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'trial_ends_at' => ['A trial end date is required for trial subscriptions.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($state, [
|
||||||
|
WorkspaceSubscription::STATE_ACTIVE,
|
||||||
|
WorkspaceSubscription::STATE_PAST_DUE,
|
||||||
|
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||||
|
], true) && blank($validated['current_period_starts_at'] ?? null)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'current_period_starts_at' => ['A current period start date is required for this subscription state.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($state, [
|
||||||
|
WorkspaceSubscription::STATE_ACTIVE,
|
||||||
|
WorkspaceSubscription::STATE_PAST_DUE,
|
||||||
|
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||||
|
WorkspaceSubscription::STATE_ENDED,
|
||||||
|
], true) && blank($validated['current_period_ends_at'] ?? null)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'current_period_ends_at' => ['A current period end date is required for this subscription state.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($actor, $workspace, $validated): WorkspaceSubscription {
|
||||||
|
$workspace->loadMissing('subscription');
|
||||||
|
|
||||||
|
$before = $workspace->subscription instanceof WorkspaceSubscription
|
||||||
|
? $this->workspaceSubscriptionAuditPayload($workspace->subscription)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$subscription = WorkspaceSubscription::query()->updateOrCreate(
|
||||||
|
['workspace_id' => (int) $workspace->getKey()],
|
||||||
|
[
|
||||||
|
'state' => (string) $validated['state'],
|
||||||
|
'billing_reference' => filled($validated['billing_reference'] ?? null)
|
||||||
|
? trim((string) $validated['billing_reference'])
|
||||||
|
: null,
|
||||||
|
'trial_ends_at' => filled($validated['trial_ends_at'] ?? null)
|
||||||
|
? Carbon::parse((string) $validated['trial_ends_at'])
|
||||||
|
: null,
|
||||||
|
'current_period_starts_at' => filled($validated['current_period_starts_at'] ?? null)
|
||||||
|
? Carbon::parse((string) $validated['current_period_starts_at'])
|
||||||
|
: null,
|
||||||
|
'current_period_ends_at' => filled($validated['current_period_ends_at'] ?? null)
|
||||||
|
? Carbon::parse((string) $validated['current_period_ends_at'])
|
||||||
|
: null,
|
||||||
|
'status_reason' => trim((string) $validated['status_reason']),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$workspace->setRelation('subscription', $subscription->fresh());
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::WorkspaceSubscriptionUpdated,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'before' => $before,
|
||||||
|
'after' => $this->workspaceSubscriptionAuditPayload($subscription),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actor: $actor,
|
||||||
|
resourceType: 'workspace_subscription',
|
||||||
|
resourceId: (string) $subscription->getKey(),
|
||||||
|
targetLabel: 'Current workspace subscription',
|
||||||
|
summary: 'Workspace subscription updated',
|
||||||
|
);
|
||||||
|
|
||||||
|
return $subscription;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void
|
public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void
|
||||||
{
|
{
|
||||||
$this->authorizeManage($actor, $workspace);
|
$this->authorizeManage($actor, $workspace);
|
||||||
@ -288,6 +392,13 @@ private function authorizeManage(User $actor, Workspace $workspace): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function authorizeCommercialLifecycleManage(PlatformUser $actor): void
|
||||||
|
{
|
||||||
|
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
|
||||||
|
throw new AuthorizationException('Missing commercial lifecycle manage capability.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function assertTenantBelongsToWorkspace(Workspace $workspace, Tenant $tenant): void
|
private function assertTenantBelongsToWorkspace(Workspace $workspace, Tenant $tenant): void
|
||||||
{
|
{
|
||||||
if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||||
@ -305,4 +416,19 @@ private function decodeStoredValue(mixed $value): mixed
|
|||||||
|
|
||||||
return json_last_error() === JSON_ERROR_NONE ? $decoded : $value;
|
return json_last_error() === JSON_ERROR_NONE ? $decoded : $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function workspaceSubscriptionAuditPayload(WorkspaceSubscription $subscription): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'state' => $subscription->state,
|
||||||
|
'billing_reference' => $subscription->billing_reference,
|
||||||
|
'trial_ends_at' => $subscription->trial_ends_at?->toAtomString(),
|
||||||
|
'current_period_starts_at' => $subscription->current_period_starts_at?->toAtomString(),
|
||||||
|
'current_period_ends_at' => $subscription->current_period_ends_at?->toAtomString(),
|
||||||
|
'status_reason' => $subscription->status_reason,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,16 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null
|
|||||||
$sectionStateCounts = $this->readinessGate->sectionStateCounts($sections);
|
$sectionStateCounts = $this->readinessGate->sectionStateCounts($sections);
|
||||||
$completeness = $this->readinessGate->completenessForSections($sections);
|
$completeness = $this->readinessGate->completenessForSections($sections);
|
||||||
$status = $this->readinessGate->statusForSections($sections);
|
$status = $this->readinessGate->statusForSections($sections);
|
||||||
|
$executiveSummarySection = collect($sections)
|
||||||
|
->firstWhere('section_key', 'executive_summary');
|
||||||
|
$controlInterpretationSection = collect($sections)
|
||||||
|
->firstWhere('section_key', 'control_interpretation');
|
||||||
|
$openRisksSection = collect($sections)
|
||||||
|
->firstWhere('section_key', 'open_risks');
|
||||||
|
$acceptedRisksSection = collect($sections)
|
||||||
|
->firstWhere('section_key', 'accepted_risks');
|
||||||
|
$operationsSection = collect($sections)
|
||||||
|
->firstWhere('section_key', 'operations_health');
|
||||||
|
|
||||||
if ($review instanceof TenantReview && $review->isPublished()) {
|
if ($review instanceof TenantReview && $review->isPublished()) {
|
||||||
$status = TenantReviewStatus::Published;
|
$status = TenantReviewStatus::Published;
|
||||||
@ -68,13 +78,260 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null
|
|||||||
'canonical_controls' => is_array(data_get($sections, '0.summary_payload.canonical_controls'))
|
'canonical_controls' => is_array(data_get($sections, '0.summary_payload.canonical_controls'))
|
||||||
? data_get($sections, '0.summary_payload.canonical_controls')
|
? data_get($sections, '0.summary_payload.canonical_controls')
|
||||||
: [],
|
: [],
|
||||||
|
'control_interpretation' => is_array(data_get($controlInterpretationSection, 'summary_payload'))
|
||||||
|
? array_merge(
|
||||||
|
data_get($controlInterpretationSection, 'summary_payload'),
|
||||||
|
[
|
||||||
|
'controls' => is_array(data_get($controlInterpretationSection, 'render_payload.entries'))
|
||||||
|
? data_get($controlInterpretationSection, 'render_payload.entries')
|
||||||
|
: [],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: [],
|
||||||
'report_count' => 2,
|
'report_count' => 2,
|
||||||
'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0),
|
'operation_count' => (int) data_get($operationsSection, 'summary_payload.operation_count', 0),
|
||||||
'highlights' => data_get($sections, '0.render_payload.highlights', []),
|
'highlights' => data_get($sections, '0.render_payload.highlights', []),
|
||||||
'recommended_next_actions' => data_get($sections, '0.render_payload.next_actions', []),
|
'recommended_next_actions' => data_get($sections, '0.render_payload.next_actions', []),
|
||||||
|
'governance_package' => $this->governancePackageSummary(
|
||||||
|
snapshot: $snapshot,
|
||||||
|
executiveSummarySection: is_array($executiveSummarySection) ? $executiveSummarySection : [],
|
||||||
|
controlInterpretationSection: is_array($controlInterpretationSection) ? $controlInterpretationSection : [],
|
||||||
|
openRisksSection: is_array($openRisksSection) ? $openRisksSection : [],
|
||||||
|
acceptedRisksSection: is_array($acceptedRisksSection) ? $acceptedRisksSection : [],
|
||||||
|
),
|
||||||
'last_composed_at' => now()->toIso8601String(),
|
'last_composed_at' => now()->toIso8601String(),
|
||||||
],
|
],
|
||||||
'sections' => $sections,
|
'sections' => $sections,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $executiveSummarySection
|
||||||
|
* @param array<string, mixed> $controlInterpretationSection
|
||||||
|
* @param array<string, mixed> $openRisksSection
|
||||||
|
* @param array<string, mixed> $acceptedRisksSection
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function governancePackageSummary(
|
||||||
|
EvidenceSnapshot $snapshot,
|
||||||
|
array $executiveSummarySection,
|
||||||
|
array $controlInterpretationSection,
|
||||||
|
array $openRisksSection,
|
||||||
|
array $acceptedRisksSection,
|
||||||
|
): array {
|
||||||
|
$executiveSummaryPayload = is_array($executiveSummarySection['summary_payload'] ?? null)
|
||||||
|
? $executiveSummarySection['summary_payload']
|
||||||
|
: [];
|
||||||
|
$executiveRenderPayload = is_array($executiveSummarySection['render_payload'] ?? null)
|
||||||
|
? $executiveSummarySection['render_payload']
|
||||||
|
: [];
|
||||||
|
$controlInterpretationSummary = is_array($controlInterpretationSection['summary_payload'] ?? null)
|
||||||
|
? $controlInterpretationSection['summary_payload']
|
||||||
|
: [];
|
||||||
|
$openRiskEntries = collect(data_get($openRisksSection, 'render_payload.entries', []))
|
||||||
|
->filter(static fn (mixed $entry): bool => is_array($entry))
|
||||||
|
->take(3)
|
||||||
|
->map(fn (array $entry): array => $this->packageFindingEntry($entry))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
$acceptedRiskEntries = collect(data_get($acceptedRisksSection, 'render_payload.entries', []))
|
||||||
|
->filter(static fn (mixed $entry): bool => is_array($entry))
|
||||||
|
->map(fn (array $entry): array => $this->packageAcceptedRiskEntry($entry))
|
||||||
|
->values();
|
||||||
|
$governanceDecisionEntries = $acceptedRiskEntries
|
||||||
|
->filter(fn (array $entry): bool => $this->requiresGovernanceDecisionFollowUp($entry))
|
||||||
|
->values();
|
||||||
|
$stableAcceptedRiskEntries = $acceptedRiskEntries
|
||||||
|
->reject(fn (array $entry): bool => $this->requiresGovernanceDecisionFollowUp($entry))
|
||||||
|
->values();
|
||||||
|
$governanceDecisions = $governanceDecisionEntries
|
||||||
|
->map(fn (array $entry): array => $this->packageGovernanceDecisionEntry($entry))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'delivery_artifact_family' => 'review_pack',
|
||||||
|
'interpretation_version' => is_string($controlInterpretationSummary['version_key'] ?? null)
|
||||||
|
? $controlInterpretationSummary['version_key']
|
||||||
|
: null,
|
||||||
|
'executive_summary' => $this->governancePackageExecutiveSummary(
|
||||||
|
executiveSummaryPayload: $executiveSummaryPayload,
|
||||||
|
executiveRenderPayload: $executiveRenderPayload,
|
||||||
|
controlInterpretationSummary: $controlInterpretationSummary,
|
||||||
|
acceptedRiskCount: $acceptedRiskEntries->count(),
|
||||||
|
),
|
||||||
|
'top_findings' => $openRiskEntries,
|
||||||
|
'accepted_risks' => $stableAcceptedRiskEntries->all(),
|
||||||
|
'governance_decisions' => $governanceDecisions,
|
||||||
|
'evidence_basis_summary' => $this->governancePackageEvidenceBasisSummary(
|
||||||
|
snapshot: $snapshot,
|
||||||
|
controlInterpretationSummary: $controlInterpretationSummary,
|
||||||
|
),
|
||||||
|
'supporting_artifact_links' => [
|
||||||
|
[
|
||||||
|
'artifact_family' => 'evidence_snapshot',
|
||||||
|
'artifact_key' => 'evidence_snapshot:'.$snapshot->getKey(),
|
||||||
|
'purpose' => 'evidence_basis',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'artifact_family' => 'review_pack',
|
||||||
|
'artifact_key' => 'review_pack:current_export',
|
||||||
|
'purpose' => 'stakeholder_delivery',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $entry
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function packageFindingEntry(array $entry): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'finding_id' => is_numeric($entry['id'] ?? null) ? (int) $entry['id'] : null,
|
||||||
|
'title' => $this->entryTitle($entry, 'Open finding'),
|
||||||
|
'severity' => is_string($entry['severity'] ?? null) ? $entry['severity'] : 'unknown',
|
||||||
|
'status' => is_string($entry['status'] ?? null) ? $entry['status'] : 'unknown',
|
||||||
|
'summary' => $this->entrySummary($entry, 'This finding remains open in the released review and should be discussed in stakeholder delivery.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $entry
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function packageAcceptedRiskEntry(array $entry): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'finding_id' => is_numeric($entry['id'] ?? null) ? (int) $entry['id'] : null,
|
||||||
|
'title' => $this->entryTitle($entry, 'Accepted risk'),
|
||||||
|
'governance_state' => is_string($entry['governance_state'] ?? null) ? $entry['governance_state'] : 'unknown',
|
||||||
|
'summary' => $this->entrySummary($entry, 'This accepted-risk entry qualifies the current governance position for stakeholder delivery.'),
|
||||||
|
'owner_label' => $this->ownerLabel($entry),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $entry
|
||||||
|
*/
|
||||||
|
private function requiresGovernanceDecisionFollowUp(array $entry): bool
|
||||||
|
{
|
||||||
|
return in_array((string) ($entry['governance_state'] ?? ''), [
|
||||||
|
'expired_exception',
|
||||||
|
'revoked_exception',
|
||||||
|
'risk_accepted_without_valid_exception',
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $entry
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function packageGovernanceDecisionEntry(array $entry): array
|
||||||
|
{
|
||||||
|
$governanceState = (string) ($entry['governance_state'] ?? 'unknown');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'finding_id' => $entry['finding_id'] ?? null,
|
||||||
|
'title' => $entry['title'] ?? 'Governance decision',
|
||||||
|
'governance_state' => $governanceState,
|
||||||
|
'summary' => match ($governanceState) {
|
||||||
|
'expired_exception' => 'The accepted-risk exception has expired and needs follow-up before stakeholder delivery.',
|
||||||
|
'revoked_exception' => 'The accepted-risk exception was revoked and needs follow-up before stakeholder delivery.',
|
||||||
|
'risk_accepted_without_valid_exception' => 'The accepted-risk entry has no currently valid exception basis and needs follow-up before stakeholder delivery.',
|
||||||
|
default => 'This governance decision needs follow-up before stakeholder delivery.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $executiveSummaryPayload
|
||||||
|
* @param array<string, mixed> $executiveRenderPayload
|
||||||
|
* @param array<string, mixed> $controlInterpretationSummary
|
||||||
|
*/
|
||||||
|
private function governancePackageExecutiveSummary(
|
||||||
|
array $executiveSummaryPayload,
|
||||||
|
array $executiveRenderPayload,
|
||||||
|
array $controlInterpretationSummary,
|
||||||
|
int $acceptedRiskCount,
|
||||||
|
): string {
|
||||||
|
$highlights = collect($executiveRenderPayload['highlights'] ?? [])
|
||||||
|
->filter(static fn (mixed $highlight): bool => is_string($highlight) && trim($highlight) !== '')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($highlights->isNotEmpty()) {
|
||||||
|
return (string) $highlights->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'This released review summarizes %d mapped control(s), %d open risk(s), and %d accepted-risk item(s) from the anchored evidence basis.',
|
||||||
|
(int) ($controlInterpretationSummary['mapped_control_count'] ?? 0),
|
||||||
|
(int) ($executiveSummaryPayload['open_risk_count'] ?? 0),
|
||||||
|
$acceptedRiskCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $controlInterpretationSummary
|
||||||
|
*/
|
||||||
|
private function governancePackageEvidenceBasisSummary(EvidenceSnapshot $snapshot, array $controlInterpretationSummary): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'Anchored to evidence snapshot #%d with %s completeness and %d mapped control(s).',
|
||||||
|
(int) $snapshot->getKey(),
|
||||||
|
(string) $snapshot->completeness_state,
|
||||||
|
(int) ($controlInterpretationSummary['mapped_control_count'] ?? 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $entry
|
||||||
|
*/
|
||||||
|
private function entryTitle(array $entry, string $fallback): string
|
||||||
|
{
|
||||||
|
foreach (['title', 'name', 'finding_title'] as $key) {
|
||||||
|
$value = $entry[$key] ?? null;
|
||||||
|
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $entry
|
||||||
|
*/
|
||||||
|
private function entrySummary(array $entry, string $fallback): string
|
||||||
|
{
|
||||||
|
foreach (['customer_summary', 'summary', 'request_reason'] as $key) {
|
||||||
|
$value = $entry[$key] ?? null;
|
||||||
|
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $entry
|
||||||
|
*/
|
||||||
|
private function ownerLabel(array $entry): ?string
|
||||||
|
{
|
||||||
|
$owner = $entry['owner'] ?? null;
|
||||||
|
|
||||||
|
if (is_array($owner)) {
|
||||||
|
$name = $owner['name'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($name) && trim($name) !== '') {
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,6 +81,7 @@ public function customerWorkspaceTenantQuery(User $user, Workspace $workspace):
|
|||||||
return Tenant::query()
|
return Tenant::query()
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
->whereIn('id', $tenantIds === [] ? [-1] : $tenantIds)
|
->whereIn('id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||||
|
->whereHas('tenantReviews', fn ($query) => $query->published())
|
||||||
->with([
|
->with([
|
||||||
'tenantReviews' => fn ($query) => $query
|
'tenantReviews' => fn ($query) => $query
|
||||||
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
|
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\EvidenceSnapshotItem;
|
use App\Models\EvidenceSnapshotItem;
|
||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
use App\Support\Findings\FindingOutcomeSemantics;
|
||||||
|
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
||||||
use App\Support\TenantReviewCompletenessState;
|
use App\Support\TenantReviewCompletenessState;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@ -15,6 +16,7 @@ final class TenantReviewSectionFactory
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
||||||
|
private readonly ComplianceEvidenceMappingV1 $complianceEvidenceMapping,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -29,8 +31,11 @@ public function make(EvidenceSnapshot $snapshot): array
|
|||||||
$baselineItem = $this->item($items, 'baseline_drift_posture');
|
$baselineItem = $this->item($items, 'baseline_drift_posture');
|
||||||
$operationsItem = $this->item($items, 'operations_summary');
|
$operationsItem = $this->item($items, 'operations_summary');
|
||||||
|
|
||||||
|
$controlInterpretation = $this->complianceEvidenceMapping->interpret($snapshot, $findingsItem);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
$this->executiveSummarySection($snapshot, $findingsItem, $permissionItem, $rolesItem, $baselineItem, $operationsItem),
|
$this->executiveSummarySection($snapshot, $findingsItem, $permissionItem, $rolesItem, $baselineItem, $operationsItem),
|
||||||
|
$controlInterpretation['section'],
|
||||||
$this->openRisksSection($findingsItem),
|
$this->openRisksSection($findingsItem),
|
||||||
$this->acceptedRisksSection($findingsItem),
|
$this->acceptedRisksSection($findingsItem),
|
||||||
$this->permissionPostureSection($permissionItem, $rolesItem),
|
$this->permissionPostureSection($permissionItem, $rolesItem),
|
||||||
|
|||||||
@ -170,6 +170,9 @@ private function queueComposition(
|
|||||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
'review_fingerprint' => $fingerprint,
|
'review_fingerprint' => $fingerprint,
|
||||||
'review_id' => $existingReview?->getKey(),
|
'review_id' => $existingReview?->getKey(),
|
||||||
|
'progress' => [
|
||||||
|
'composite' => $this->reviewComposeProgressMetadata($snapshot),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
initiator: $user,
|
initiator: $user,
|
||||||
);
|
);
|
||||||
@ -247,4 +250,71 @@ private function findExistingMutableReview(Tenant $tenant, string $fingerprint):
|
|||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label: string, operation_count: int, failed_count: int, partial_count: int}
|
||||||
|
*/
|
||||||
|
private function reviewComposeProgressMetadata(EvidenceSnapshot $snapshot): array
|
||||||
|
{
|
||||||
|
$snapshot->loadMissing('items');
|
||||||
|
|
||||||
|
$operationsSummary = $snapshot->items
|
||||||
|
->firstWhere('dimension_key', 'operations_summary')
|
||||||
|
?->summary_payload;
|
||||||
|
|
||||||
|
$operationCount = is_numeric(data_get($operationsSummary, 'operation_count'))
|
||||||
|
? (int) data_get($operationsSummary, 'operation_count')
|
||||||
|
: 0;
|
||||||
|
$failedCount = is_numeric(data_get($operationsSummary, 'failed_count'))
|
||||||
|
? (int) data_get($operationsSummary, 'failed_count')
|
||||||
|
: 0;
|
||||||
|
$partialCount = is_numeric(data_get($operationsSummary, 'partial_count'))
|
||||||
|
? (int) data_get($operationsSummary, 'partial_count')
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => $this->reviewComposeProgressLabel($operationCount, $failedCount, $partialCount),
|
||||||
|
'operation_count' => $operationCount,
|
||||||
|
'failed_count' => $failedCount,
|
||||||
|
'partial_count' => $partialCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewComposeProgressLabel(int $operationCount, int $failedCount, int $partialCount): string
|
||||||
|
{
|
||||||
|
$baseLabel = $operationCount > 0
|
||||||
|
? sprintf('Review composition is aggregating %d %s.', $operationCount, $operationCount === 1 ? 'operation' : 'operations')
|
||||||
|
: 'Review composition is aggregating related operations.';
|
||||||
|
|
||||||
|
if ($failedCount > 0 && $partialCount > 0) {
|
||||||
|
return sprintf(
|
||||||
|
'%s %d %s and %d %s currently need review.',
|
||||||
|
$baseLabel,
|
||||||
|
$failedCount,
|
||||||
|
$failedCount === 1 ? 'failed operation' : 'failed operations',
|
||||||
|
$partialCount,
|
||||||
|
$partialCount === 1 ? 'partial operation' : 'partial operations',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failedCount > 0) {
|
||||||
|
return sprintf(
|
||||||
|
'%s %d %s currently need review.',
|
||||||
|
$baseLabel,
|
||||||
|
$failedCount,
|
||||||
|
$failedCount === 1 ? 'failed operation' : 'failed operations',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($partialCount > 0) {
|
||||||
|
return sprintf(
|
||||||
|
'%s %d %s currently need review.',
|
||||||
|
$baseLabel,
|
||||||
|
$partialCount,
|
||||||
|
$partialCount === 1 ? 'partial operation' : 'partial operations',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $baseLabel;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,9 @@ enum AuditActionId: string
|
|||||||
// Diagnostics / repair actions.
|
// Diagnostics / repair actions.
|
||||||
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
|
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
|
||||||
|
|
||||||
|
case PolicyProviderMissingDetected = 'policy.provider_missing_detected';
|
||||||
|
case PolicyProviderMissingCleared = 'policy.provider_missing_cleared';
|
||||||
|
|
||||||
// Managed tenant onboarding wizard.
|
// Managed tenant onboarding wizard.
|
||||||
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
|
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
|
||||||
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
|
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
|
||||||
@ -58,6 +61,7 @@ enum AuditActionId: string
|
|||||||
|
|
||||||
case WorkspaceSettingUpdated = 'workspace_setting.updated';
|
case WorkspaceSettingUpdated = 'workspace_setting.updated';
|
||||||
case WorkspaceSettingReset = 'workspace_setting.reset';
|
case WorkspaceSettingReset = 'workspace_setting.reset';
|
||||||
|
case WorkspaceSubscriptionUpdated = 'workspace_subscription.updated';
|
||||||
|
|
||||||
case BaselineProfileCreated = 'baseline_profile.created';
|
case BaselineProfileCreated = 'baseline_profile.created';
|
||||||
case BaselineProfileUpdated = 'baseline_profile.updated';
|
case BaselineProfileUpdated = 'baseline_profile.updated';
|
||||||
@ -70,6 +74,8 @@ enum AuditActionId: string
|
|||||||
case BaselineCompareCompleted = 'baseline_compare.completed';
|
case BaselineCompareCompleted = 'baseline_compare.completed';
|
||||||
case BaselineCompareFailed = 'baseline_compare.failed';
|
case BaselineCompareFailed = 'baseline_compare.failed';
|
||||||
case CrossTenantPromotionPreflightGenerated = 'cross_tenant_promotion_preflight.generated';
|
case CrossTenantPromotionPreflightGenerated = 'cross_tenant_promotion_preflight.generated';
|
||||||
|
case CrossTenantPromotionExecutionQueued = 'cross_tenant_promotion_execution.queued';
|
||||||
|
case CrossTenantPromotionExecutionCompleted = 'cross_tenant_promotion_execution.completed';
|
||||||
case BaselineAssignmentCreated = 'baseline_assignment.created';
|
case BaselineAssignmentCreated = 'baseline_assignment.created';
|
||||||
case BaselineAssignmentUpdated = 'baseline_assignment.updated';
|
case BaselineAssignmentUpdated = 'baseline_assignment.updated';
|
||||||
case BaselineAssignmentDeleted = 'baseline_assignment.deleted';
|
case BaselineAssignmentDeleted = 'baseline_assignment.deleted';
|
||||||
@ -91,6 +97,7 @@ enum AuditActionId: string
|
|||||||
case EvidenceSnapshotCreated = 'evidence_snapshot.created';
|
case EvidenceSnapshotCreated = 'evidence_snapshot.created';
|
||||||
case EvidenceSnapshotRefreshed = 'evidence_snapshot.refreshed';
|
case EvidenceSnapshotRefreshed = 'evidence_snapshot.refreshed';
|
||||||
case EvidenceSnapshotExpired = 'evidence_snapshot.expired';
|
case EvidenceSnapshotExpired = 'evidence_snapshot.expired';
|
||||||
|
case EvidenceSnapshotOpened = 'evidence_snapshot.opened';
|
||||||
case TenantReviewCreated = 'tenant_review.created';
|
case TenantReviewCreated = 'tenant_review.created';
|
||||||
case TenantReviewRefreshed = 'tenant_review.refreshed';
|
case TenantReviewRefreshed = 'tenant_review.refreshed';
|
||||||
case TenantReviewPublished = 'tenant_review.published';
|
case TenantReviewPublished = 'tenant_review.published';
|
||||||
@ -98,6 +105,7 @@ enum AuditActionId: string
|
|||||||
case TenantReviewOpened = 'tenant_review.opened';
|
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 CustomerReviewWorkspaceOpened = 'customer_review_workspace.opened';
|
||||||
case ReviewPackDownloaded = 'review_pack.downloaded';
|
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';
|
||||||
@ -182,6 +190,8 @@ private static function labels(): array
|
|||||||
self::TenantMembershipRoleChange->value => 'Tenant member role change',
|
self::TenantMembershipRoleChange->value => 'Tenant member role change',
|
||||||
self::TenantMembershipRemove->value => 'Tenant member removal',
|
self::TenantMembershipRemove->value => 'Tenant member removal',
|
||||||
self::TenantMembershipLastOwnerBlocked->value => 'Tenant last-owner protection',
|
self::TenantMembershipLastOwnerBlocked->value => 'Tenant last-owner protection',
|
||||||
|
self::PolicyProviderMissingDetected->value => 'Policy provider missing detected',
|
||||||
|
self::PolicyProviderMissingCleared->value => 'Policy provider missing cleared',
|
||||||
self::ManagedTenantOnboardingStart->value => 'Managed tenant onboarding start',
|
self::ManagedTenantOnboardingStart->value => 'Managed tenant onboarding start',
|
||||||
self::ManagedTenantOnboardingResume->value => 'Managed tenant onboarding resume',
|
self::ManagedTenantOnboardingResume->value => 'Managed tenant onboarding resume',
|
||||||
self::ManagedTenantOnboardingDraftSelected->value => 'Managed tenant onboarding draft selected',
|
self::ManagedTenantOnboardingDraftSelected->value => 'Managed tenant onboarding draft selected',
|
||||||
@ -209,6 +219,7 @@ private static function labels(): array
|
|||||||
self::AlertRuleDisabled->value => 'Alert rule disabled',
|
self::AlertRuleDisabled->value => 'Alert rule disabled',
|
||||||
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
|
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
|
||||||
self::WorkspaceSettingReset->value => 'Workspace setting reset',
|
self::WorkspaceSettingReset->value => 'Workspace setting reset',
|
||||||
|
self::WorkspaceSubscriptionUpdated->value => 'Workspace subscription updated',
|
||||||
self::BaselineProfileCreated->value => 'Baseline profile created',
|
self::BaselineProfileCreated->value => 'Baseline profile created',
|
||||||
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
||||||
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
||||||
@ -220,6 +231,8 @@ private static function labels(): array
|
|||||||
self::BaselineCompareCompleted->value => 'Baseline compare completed',
|
self::BaselineCompareCompleted->value => 'Baseline compare completed',
|
||||||
self::BaselineCompareFailed->value => 'Baseline compare failed',
|
self::BaselineCompareFailed->value => 'Baseline compare failed',
|
||||||
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
|
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
|
||||||
|
self::CrossTenantPromotionExecutionQueued->value => 'Cross-tenant promotion execution queued',
|
||||||
|
self::CrossTenantPromotionExecutionCompleted->value => 'Cross-tenant promotion execution completed',
|
||||||
self::BaselineAssignmentCreated->value => 'Baseline assignment created',
|
self::BaselineAssignmentCreated->value => 'Baseline assignment created',
|
||||||
self::BaselineAssignmentUpdated->value => 'Baseline assignment updated',
|
self::BaselineAssignmentUpdated->value => 'Baseline assignment updated',
|
||||||
self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted',
|
self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted',
|
||||||
@ -241,6 +254,7 @@ private static function labels(): array
|
|||||||
self::EvidenceSnapshotCreated->value => 'Evidence snapshot created',
|
self::EvidenceSnapshotCreated->value => 'Evidence snapshot created',
|
||||||
self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed',
|
self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed',
|
||||||
self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired',
|
self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired',
|
||||||
|
self::EvidenceSnapshotOpened->value => 'Evidence snapshot opened',
|
||||||
self::TenantReviewCreated->value => 'Tenant review created',
|
self::TenantReviewCreated->value => 'Tenant review created',
|
||||||
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
||||||
self::TenantReviewPublished->value => 'Tenant review published',
|
self::TenantReviewPublished->value => 'Tenant review published',
|
||||||
@ -248,6 +262,7 @@ private static function labels(): array
|
|||||||
self::TenantReviewOpened->value => 'Tenant review opened',
|
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::CustomerReviewWorkspaceOpened->value => 'Customer review workspace opened',
|
||||||
self::ReviewPackDownloaded->value => 'Review pack downloaded',
|
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',
|
||||||
@ -308,13 +323,18 @@ private static function summaries(): array
|
|||||||
self::TenantMembershipRoleChange->value => 'Tenant member role changed',
|
self::TenantMembershipRoleChange->value => 'Tenant member role changed',
|
||||||
self::TenantMembershipRemove->value => 'Tenant member removed',
|
self::TenantMembershipRemove->value => 'Tenant member removed',
|
||||||
self::TenantMembershipLastOwnerBlocked->value => 'Tenant last-owner protection triggered',
|
self::TenantMembershipLastOwnerBlocked->value => 'Tenant last-owner protection triggered',
|
||||||
|
self::PolicyProviderMissingDetected->value => 'Policy marked provider missing',
|
||||||
|
self::PolicyProviderMissingCleared->value => 'Policy provider presence restored',
|
||||||
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
|
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
|
||||||
self::WorkspaceSettingReset->value => 'Workspace setting reset',
|
self::WorkspaceSettingReset->value => 'Workspace setting reset',
|
||||||
|
self::WorkspaceSubscriptionUpdated->value => 'Workspace subscription updated',
|
||||||
self::BaselineProfileCreated->value => 'Baseline profile created',
|
self::BaselineProfileCreated->value => 'Baseline profile created',
|
||||||
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
||||||
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
||||||
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
|
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
|
||||||
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
|
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
|
||||||
|
self::CrossTenantPromotionExecutionQueued->value => 'Cross-tenant promotion execution queued',
|
||||||
|
self::CrossTenantPromotionExecutionCompleted->value => 'Cross-tenant promotion execution completed',
|
||||||
self::AlertDestinationCreated->value => 'Alert destination created',
|
self::AlertDestinationCreated->value => 'Alert destination created',
|
||||||
self::AlertDestinationUpdated->value => 'Alert destination updated',
|
self::AlertDestinationUpdated->value => 'Alert destination updated',
|
||||||
self::AlertDestinationDeleted->value => 'Alert destination deleted',
|
self::AlertDestinationDeleted->value => 'Alert destination deleted',
|
||||||
@ -337,6 +357,7 @@ private static function summaries(): array
|
|||||||
self::EvidenceSnapshotCreated->value => 'Evidence snapshot created',
|
self::EvidenceSnapshotCreated->value => 'Evidence snapshot created',
|
||||||
self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed',
|
self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed',
|
||||||
self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired',
|
self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired',
|
||||||
|
self::EvidenceSnapshotOpened->value => 'Evidence snapshot opened',
|
||||||
self::TenantReviewCreated->value => 'Tenant review created',
|
self::TenantReviewCreated->value => 'Tenant review created',
|
||||||
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
||||||
self::TenantReviewPublished->value => 'Tenant review published',
|
self::TenantReviewPublished->value => 'Tenant review published',
|
||||||
@ -344,6 +365,7 @@ private static function summaries(): array
|
|||||||
self::TenantReviewOpened->value => 'Tenant review opened',
|
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::CustomerReviewWorkspaceOpened->value => 'Customer review workspace opened',
|
||||||
self::ReviewPackDownloaded->value => 'Review pack downloaded',
|
self::ReviewPackDownloaded->value => 'Review pack downloaded',
|
||||||
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
||||||
self::SupportRequestCreated->value => 'Support request created',
|
self::SupportRequestCreated->value => 'Support request created',
|
||||||
|
|||||||
@ -205,8 +205,8 @@ public function forPolicyVersion(PolicyVersion $policyVersion): BackupQualitySum
|
|||||||
compactSummary: $this->compactSummaryFromHighlights($qualityHighlights, $snapshotMode),
|
compactSummary: $this->compactSummaryFromHighlights($qualityHighlights, $snapshotMode),
|
||||||
summaryMessage: $this->singleRecordSummaryMessage($qualityHighlights, $snapshotMode),
|
summaryMessage: $this->singleRecordSummaryMessage($qualityHighlights, $snapshotMode),
|
||||||
nextAction: $degradationFamilies === []
|
nextAction: $degradationFamilies === []
|
||||||
? 'Open the version detail if you need raw settings or diff context.'
|
? $this->text('next_action_open_version_detail')
|
||||||
: 'Prefer a stronger version or inspect the version detail before restore.',
|
: $this->text('next_action_prefer_stronger_version'),
|
||||||
positiveClaimBoundary: $this->positiveClaimBoundary(),
|
positiveClaimBoundary: $this->positiveClaimBoundary(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -295,25 +295,25 @@ private function singleRecordHighlights(
|
|||||||
$highlights = [];
|
$highlights = [];
|
||||||
|
|
||||||
if ($snapshotMode === 'metadata_only') {
|
if ($snapshotMode === 'metadata_only') {
|
||||||
$highlights[] = 'Metadata only';
|
$highlights[] = $this->text('quality_highlight_metadata_only');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($hasAssignmentIssues) {
|
if ($hasAssignmentIssues) {
|
||||||
$highlights[] = 'Assignment fetch failed';
|
$highlights[] = $this->text('quality_highlight_assignment_fetch_failed');
|
||||||
} elseif ($assignmentCaptureReason === 'separate_role_assignments') {
|
} elseif ($assignmentCaptureReason === 'separate_role_assignments') {
|
||||||
$highlights[] = 'Assignments captured separately';
|
$highlights[] = $this->text('quality_highlight_assignments_captured_separately');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($hasOrphanedAssignments) {
|
if ($hasOrphanedAssignments) {
|
||||||
$highlights[] = 'Orphaned assignments';
|
$highlights[] = $this->text('quality_highlight_orphaned_assignments');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($integrityWarning !== null) {
|
if ($integrityWarning !== null) {
|
||||||
$highlights[] = 'Integrity warning';
|
$highlights[] = $this->text('quality_highlight_integrity_warning');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($snapshotMode === 'unknown' && $highlights === []) {
|
if ($snapshotMode === 'unknown' && $highlights === []) {
|
||||||
$highlights[] = 'Unknown quality';
|
$highlights[] = $this->text('quality_highlight_unknown_quality');
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values(array_unique($highlights));
|
return array_values(array_unique($highlights));
|
||||||
@ -326,9 +326,9 @@ private function compactSummaryFromHighlights(array $qualityHighlights, string $
|
|||||||
}
|
}
|
||||||
|
|
||||||
return match ($snapshotMode) {
|
return match ($snapshotMode) {
|
||||||
'full' => 'Full payload',
|
'full' => $this->text('compact_summary_full_payload'),
|
||||||
'unknown' => 'Unknown quality',
|
'unknown' => $this->text('compact_summary_unknown_quality'),
|
||||||
default => 'No degradations detected',
|
default => $this->text('compact_summary_no_degradations_detected'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -336,15 +336,20 @@ private function singleRecordSummaryMessage(array $qualityHighlights, string $sn
|
|||||||
{
|
{
|
||||||
if ($qualityHighlights === []) {
|
if ($qualityHighlights === []) {
|
||||||
return match ($snapshotMode) {
|
return match ($snapshotMode) {
|
||||||
'full' => 'No degradations were detected from the captured snapshot and assignment metadata.',
|
'full' => $this->text('summary_full_no_degradations'),
|
||||||
'unknown' => 'Quality is unknown because this record lacks enough completeness metadata to justify a stronger claim.',
|
'unknown' => $this->text('summary_unknown_quality'),
|
||||||
default => 'No degradations were detected.',
|
default => $this->text('summary_no_degradations'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return implode(' • ', $qualityHighlights).'.';
|
return implode(' • ', $qualityHighlights).'.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function text(string $key, array $replace = []): string
|
||||||
|
{
|
||||||
|
return __('localization.policy.versions.'.$key, $replace);
|
||||||
|
}
|
||||||
|
|
||||||
private function aggregateSnapshotMode(int $totalItems, int $metadataOnlyCount, int $unknownQualityCount): string
|
private function aggregateSnapshotMode(int $totalItems, int $metadataOnlyCount, int $unknownQualityCount): string
|
||||||
{
|
{
|
||||||
if ($totalItems === 0) {
|
if ($totalItems === 0) {
|
||||||
|
|||||||
@ -18,7 +18,9 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::GovernanceArtifactExistence->value => Domains\GovernanceArtifactExistenceBadge::class,
|
BadgeDomain::GovernanceArtifactExistence->value => Domains\GovernanceArtifactExistenceBadge::class,
|
||||||
BadgeDomain::GovernanceArtifactContent->value => Domains\GovernanceArtifactContentBadge::class,
|
BadgeDomain::GovernanceArtifactContent->value => Domains\GovernanceArtifactContentBadge::class,
|
||||||
BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class,
|
BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class,
|
||||||
|
BadgeDomain::GovernanceArtifactLifecycle->value => Domains\GovernanceArtifactLifecycleBadge::class,
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness->value => Domains\GovernanceArtifactPublicationReadinessBadge::class,
|
BadgeDomain::GovernanceArtifactPublicationReadiness->value => Domains\GovernanceArtifactPublicationReadinessBadge::class,
|
||||||
|
BadgeDomain::GovernanceArtifactRetention->value => Domains\GovernanceArtifactRetentionBadge::class,
|
||||||
BadgeDomain::GovernanceArtifactActionability->value => Domains\GovernanceArtifactActionabilityBadge::class,
|
BadgeDomain::GovernanceArtifactActionability->value => Domains\GovernanceArtifactActionabilityBadge::class,
|
||||||
BadgeDomain::OperatorExplanationEvaluationResult->value => Domains\OperatorExplanationEvaluationResultBadge::class,
|
BadgeDomain::OperatorExplanationEvaluationResult->value => Domains\OperatorExplanationEvaluationResultBadge::class,
|
||||||
BadgeDomain::OperatorExplanationTrustworthiness->value => Domains\OperatorExplanationTrustworthinessBadge::class,
|
BadgeDomain::OperatorExplanationTrustworthiness->value => Domains\OperatorExplanationTrustworthinessBadge::class,
|
||||||
@ -43,6 +45,7 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
|
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
|
||||||
BadgeDomain::PolicyRestoreMode->value => Domains\PolicyRestoreModeBadge::class,
|
BadgeDomain::PolicyRestoreMode->value => Domains\PolicyRestoreModeBadge::class,
|
||||||
BadgeDomain::PolicyRisk->value => Domains\PolicyRiskBadge::class,
|
BadgeDomain::PolicyRisk->value => Domains\PolicyRiskBadge::class,
|
||||||
|
BadgeDomain::PolicyProviderPresence->value => Domains\PolicyProviderPresenceBadge::class,
|
||||||
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
|
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
|
||||||
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
|
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
|
||||||
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
||||||
|
|||||||
@ -9,7 +9,9 @@ enum BadgeDomain: string
|
|||||||
case GovernanceArtifactExistence = 'governance_artifact_existence';
|
case GovernanceArtifactExistence = 'governance_artifact_existence';
|
||||||
case GovernanceArtifactContent = 'governance_artifact_content';
|
case GovernanceArtifactContent = 'governance_artifact_content';
|
||||||
case GovernanceArtifactFreshness = 'governance_artifact_freshness';
|
case GovernanceArtifactFreshness = 'governance_artifact_freshness';
|
||||||
|
case GovernanceArtifactLifecycle = 'governance_artifact_lifecycle';
|
||||||
case GovernanceArtifactPublicationReadiness = 'governance_artifact_publication_readiness';
|
case GovernanceArtifactPublicationReadiness = 'governance_artifact_publication_readiness';
|
||||||
|
case GovernanceArtifactRetention = 'governance_artifact_retention';
|
||||||
case GovernanceArtifactActionability = 'governance_artifact_actionability';
|
case GovernanceArtifactActionability = 'governance_artifact_actionability';
|
||||||
case OperatorExplanationEvaluationResult = 'operator_explanation_evaluation_result';
|
case OperatorExplanationEvaluationResult = 'operator_explanation_evaluation_result';
|
||||||
case OperatorExplanationTrustworthiness = 'operator_explanation_trustworthiness';
|
case OperatorExplanationTrustworthiness = 'operator_explanation_trustworthiness';
|
||||||
@ -34,6 +36,7 @@ enum BadgeDomain: string
|
|||||||
case PolicySnapshotMode = 'policy_snapshot_mode';
|
case PolicySnapshotMode = 'policy_snapshot_mode';
|
||||||
case PolicyRestoreMode = 'policy_restore_mode';
|
case PolicyRestoreMode = 'policy_restore_mode';
|
||||||
case PolicyRisk = 'policy_risk';
|
case PolicyRisk = 'policy_risk';
|
||||||
|
case PolicyProviderPresence = 'policy_provider_presence';
|
||||||
case IgnoredAt = 'ignored_at';
|
case IgnoredAt = 'ignored_at';
|
||||||
case RestorePreviewDecision = 'restore_preview_decision';
|
case RestorePreviewDecision = 'restore_preview_decision';
|
||||||
case RestoreResultStatus = 'restore_result_status';
|
case RestoreResultStatus = 'restore_result_status';
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
|
||||||
|
final class GovernanceArtifactLifecycleBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
return match (BadgeCatalog::normalizeState($value)) {
|
||||||
|
'current' => new BadgeSpec('Current', 'success', 'heroicon-m-check-circle'),
|
||||||
|
'historical' => new BadgeSpec('Historical', 'gray', 'heroicon-m-archive-box'),
|
||||||
|
'superseded' => new BadgeSpec('Superseded', 'warning', 'heroicon-m-arrow-path'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
|
||||||
|
final class GovernanceArtifactRetentionBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
return match (BadgeCatalog::normalizeState($value)) {
|
||||||
|
'retained' => new BadgeSpec('Retained', 'info', 'heroicon-m-archive-box'),
|
||||||
|
'hold' => new BadgeSpec('On hold', 'warning', 'heroicon-m-lock-closed'),
|
||||||
|
'deletion_requested' => new BadgeSpec('Deletion requested', 'danger', 'heroicon-m-trash'),
|
||||||
|
'expired_direct_access' => new BadgeSpec('Direct access expired', 'gray', 'heroicon-m-clock'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
|
||||||
|
final class PolicyProviderPresenceBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
return match (BadgeCatalog::normalizeState($value)) {
|
||||||
|
Policy::VISIBILITY_ACTIVE => new BadgeSpec(__('localization.policy.badges.active'), 'success', 'heroicon-m-check-circle'),
|
||||||
|
Policy::VISIBILITY_IGNORED_LOCALLY => new BadgeSpec(__('localization.policy.badges.ignored_locally'), 'warning', 'heroicon-m-eye-slash'),
|
||||||
|
Policy::VISIBILITY_PROVIDER_MISSING => new BadgeSpec(__('localization.policy.badges.source_unavailable'), 'warning', 'heroicon-m-exclamation-triangle'),
|
||||||
|
Policy::VISIBILITY_IGNORED_LOCALLY_PROVIDER_MISSING => new BadgeSpec(__('localization.policy.badges.ignored_source_unavailable'), 'danger', 'heroicon-m-exclamation-triangle'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,8 +13,8 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
$state = BadgeCatalog::normalizeState($value);
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
'full' => new BadgeSpec('Full', 'success', 'heroicon-m-check-circle'),
|
'full' => new BadgeSpec(__('localization.policy.versions.snapshot_mode_full'), 'success', 'heroicon-m-check-circle'),
|
||||||
'metadata_only' => new BadgeSpec('Metadata only', 'warning', 'heroicon-m-exclamation-triangle'),
|
'metadata_only' => new BadgeSpec(__('localization.policy.versions.snapshot_mode_metadata_only'), 'warning', 'heroicon-m-exclamation-triangle'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -120,12 +120,12 @@ private static function platform(mixed $value): TagBadgeSpec
|
|||||||
->toString();
|
->toString();
|
||||||
|
|
||||||
$label = match ($normalized) {
|
$label = match ($normalized) {
|
||||||
'windows' => 'Windows',
|
'windows' => __('localization.policy.common.platform_label_windows'),
|
||||||
'android' => 'Android',
|
'android' => __('localization.policy.common.platform_label_android'),
|
||||||
'ios' => 'iOS',
|
'ios' => __('localization.policy.common.platform_label_ios'),
|
||||||
'macos' => 'macOS',
|
'macos' => __('localization.policy.common.platform_label_macos'),
|
||||||
'all' => 'All',
|
'all' => __('localization.policy.common.platform_label_all'),
|
||||||
'mobile' => 'Mobile',
|
'mobile' => __('localization.policy.common.platform_label_mobile'),
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,515 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Governance\Controls;
|
||||||
|
|
||||||
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Models\EvidenceSnapshotItem;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Support\TenantReviewCompletenessState;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final readonly class ComplianceEvidenceMappingV1
|
||||||
|
{
|
||||||
|
public const string VERSION_KEY = 'compliance_evidence_mapping.v1';
|
||||||
|
|
||||||
|
public const string SECTION_KEY = 'control_interpretation';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private CanonicalControlCatalog $catalog,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* summary: array<string, mixed>,
|
||||||
|
* section: array<string, mixed>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function interpret(EvidenceSnapshot $snapshot, ?EvidenceSnapshotItem $findingsItem): array
|
||||||
|
{
|
||||||
|
$findingsSummary = $this->findingsSummary($findingsItem);
|
||||||
|
$entries = $this->findingEntries($findingsSummary);
|
||||||
|
$unresolvedEntryCount = $entries
|
||||||
|
->filter(static fn (array $entry): bool => (string) data_get($entry, 'canonical_control_resolution.status') !== 'resolved')
|
||||||
|
->count();
|
||||||
|
$controls = $this->controlDefinitions($findingsSummary, $entries);
|
||||||
|
$snapshotLimitations = $this->snapshotLimitations($snapshot, $findingsItem, $unresolvedEntryCount);
|
||||||
|
|
||||||
|
$controlSummaries = $controls
|
||||||
|
->map(fn (CanonicalControlDefinition $definition): array => $this->controlSummary(
|
||||||
|
definition: $definition,
|
||||||
|
entries: $this->entriesForControl($entries, $definition->controlKey),
|
||||||
|
snapshotLimitations: $snapshotLimitations,
|
||||||
|
))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$globalLimitations = $this->globalLimitations($controlSummaries, $snapshotLimitations, $controls->isEmpty(), $unresolvedEntryCount);
|
||||||
|
$limitationCounts = $this->limitationCounts($controlSummaries, $globalLimitations);
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'version_key' => self::VERSION_KEY,
|
||||||
|
'display_label' => 'Compliance evidence mapping v1',
|
||||||
|
'non_certification_disclosure' => 'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.',
|
||||||
|
'mapped_control_count' => count($controlSummaries),
|
||||||
|
'follow_up_required_count' => collect($controlSummaries)
|
||||||
|
->where('readiness_bucket', 'follow_up_required')
|
||||||
|
->count(),
|
||||||
|
'limitation_counts' => $limitationCounts,
|
||||||
|
'limitations' => $globalLimitations,
|
||||||
|
'controls' => $controlSummaries,
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => $summary,
|
||||||
|
'section' => [
|
||||||
|
'section_key' => self::SECTION_KEY,
|
||||||
|
'title' => 'Control readiness interpretation',
|
||||||
|
'sort_order' => 15,
|
||||||
|
'required' => true,
|
||||||
|
'completeness_state' => $this->sectionCompleteness($findingsItem, $controls->isEmpty(), $snapshotLimitations),
|
||||||
|
'source_snapshot_fingerprint' => $this->sourceFingerprint($findingsItem) ?? (string) $snapshot->fingerprint,
|
||||||
|
'summary_payload' => Arr::except($summary, ['controls']),
|
||||||
|
'render_payload' => [
|
||||||
|
'entries' => array_map(
|
||||||
|
fn (array $control): array => $this->controlExplanation($control, $snapshot),
|
||||||
|
$controlSummaries,
|
||||||
|
),
|
||||||
|
'disclosure' => $summary['non_certification_disclosure'],
|
||||||
|
'next_actions' => $this->sectionNextActions($controlSummaries, $globalLimitations),
|
||||||
|
'empty_state' => $controlSummaries === []
|
||||||
|
? 'No canonical controls are mapped in this released review. Treat the control view as partial until evidence references can be mapped.'
|
||||||
|
: null,
|
||||||
|
],
|
||||||
|
'measured_at' => $findingsItem?->measured_at ?? $snapshot->generated_at,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function findingsSummary(?EvidenceSnapshotItem $findingsItem): array
|
||||||
|
{
|
||||||
|
return is_array($findingsItem?->summary_payload) ? $findingsItem->summary_payload : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $findingsSummary
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function findingEntries(array $findingsSummary): Collection
|
||||||
|
{
|
||||||
|
return collect(Arr::wrap($findingsSummary['entries'] ?? []))
|
||||||
|
->filter(static fn (mixed $entry): bool => is_array($entry))
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $findingsSummary
|
||||||
|
* @param Collection<int, array<string, mixed>> $entries
|
||||||
|
* @return Collection<int, CanonicalControlDefinition>
|
||||||
|
*/
|
||||||
|
private function controlDefinitions(array $findingsSummary, Collection $entries): Collection
|
||||||
|
{
|
||||||
|
$summaryControls = collect(Arr::wrap($findingsSummary['canonical_controls'] ?? []))
|
||||||
|
->filter(static fn (mixed $control): bool => is_array($control));
|
||||||
|
|
||||||
|
$entryControls = $entries
|
||||||
|
->map(static fn (array $entry): mixed => data_get($entry, 'canonical_control_resolution.control'))
|
||||||
|
->filter(static fn (mixed $control): bool => is_array($control));
|
||||||
|
|
||||||
|
return $summaryControls
|
||||||
|
->merge($entryControls)
|
||||||
|
->map(fn (array $control): ?CanonicalControlDefinition => $this->definitionFor($control))
|
||||||
|
->filter()
|
||||||
|
->unique(static fn (CanonicalControlDefinition $definition): string => $definition->controlKey)
|
||||||
|
->sortBy(static fn (CanonicalControlDefinition $definition): string => $definition->name)
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $control
|
||||||
|
*/
|
||||||
|
private function definitionFor(array $control): ?CanonicalControlDefinition
|
||||||
|
{
|
||||||
|
$controlKey = $control['control_key'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($controlKey) || trim($controlKey) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->catalog->find($controlKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, array<string, mixed>> $entries
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function entriesForControl(Collection $entries, string $controlKey): Collection
|
||||||
|
{
|
||||||
|
return $entries
|
||||||
|
->filter(static fn (array $entry): bool => (string) data_get($entry, 'canonical_control_resolution.control.control_key') === $controlKey)
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, array<string, mixed>> $entries
|
||||||
|
* @param list<string> $snapshotLimitations
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function controlSummary(CanonicalControlDefinition $definition, Collection $entries, array $snapshotLimitations): array
|
||||||
|
{
|
||||||
|
$openEntries = $entries->filter(static fn (array $entry): bool => in_array((string) ($entry['status'] ?? ''), Finding::openStatuses(), true));
|
||||||
|
$acceptedRiskEntries = $entries->filter(static fn (array $entry): bool => (string) ($entry['status'] ?? '') === Finding::STATUS_RISK_ACCEPTED);
|
||||||
|
$governanceWarnings = $entries->filter(static fn (array $entry): bool => self::hasGovernanceWarning($entry));
|
||||||
|
$limitationFlags = $this->controlLimitations($acceptedRiskEntries->count(), $snapshotLimitations);
|
||||||
|
$readinessBucket = $this->readinessBucket(
|
||||||
|
openCount: $openEntries->count(),
|
||||||
|
acceptedRiskCount: $acceptedRiskEntries->count(),
|
||||||
|
governanceWarningCount: $governanceWarnings->count(),
|
||||||
|
limitationFlags: $limitationFlags,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'control_key' => $definition->controlKey,
|
||||||
|
'control_name' => $definition->name,
|
||||||
|
'domain_key' => $definition->domainKey,
|
||||||
|
'readiness_bucket' => $readinessBucket,
|
||||||
|
'readiness_label' => self::readinessLabel($readinessBucket),
|
||||||
|
'limitation_flags' => $limitationFlags,
|
||||||
|
'limitation_labels' => array_map(self::limitationLabel(...), $limitationFlags),
|
||||||
|
'customer_summary' => $this->customerSummary($definition, $readinessBucket, $openEntries->count(), $acceptedRiskEntries->count()),
|
||||||
|
'evidence_basis_summary' => $this->evidenceBasisSummary($entries->count(), $openEntries->count(), $acceptedRiskEntries->count()),
|
||||||
|
'accepted_risk_summary' => $acceptedRiskEntries->isEmpty()
|
||||||
|
? null
|
||||||
|
: $this->acceptedRiskSummary($acceptedRiskEntries, $governanceWarnings->count()),
|
||||||
|
'recommended_next_action' => $this->recommendedNextAction($readinessBucket, $acceptedRiskEntries->count(), $limitationFlags),
|
||||||
|
'detail_anchor' => 'control-'.$definition->controlKey,
|
||||||
|
'supporting_finding_ids' => $entries
|
||||||
|
->pluck('id')
|
||||||
|
->filter(static fn (mixed $id): bool => is_numeric($id))
|
||||||
|
->map(static fn (mixed $id): int => (int) $id)
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
'finding_count' => $entries->count(),
|
||||||
|
'open_finding_count' => $openEntries->count(),
|
||||||
|
'accepted_risk_count' => $acceptedRiskEntries->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, array<string, mixed>> $acceptedRiskEntries
|
||||||
|
*/
|
||||||
|
private function acceptedRiskSummary(Collection $acceptedRiskEntries, int $governanceWarningCount): string
|
||||||
|
{
|
||||||
|
if ($governanceWarningCount > 0) {
|
||||||
|
return sprintf(
|
||||||
|
'%d accepted-risk finding(s) need governance follow-up before relying on this interpretation.',
|
||||||
|
$acceptedRiskEntries->count(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'%d accepted-risk finding(s) are part of the evidence basis and qualify the readiness view.',
|
||||||
|
$acceptedRiskEntries->count(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $snapshotLimitations
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function controlLimitations(int $acceptedRiskCount, array $snapshotLimitations): array
|
||||||
|
{
|
||||||
|
$limitations = $snapshotLimitations;
|
||||||
|
|
||||||
|
if ($acceptedRiskCount > 0) {
|
||||||
|
$limitations[] = 'accepted_risk_influenced';
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($limitations));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $limitationFlags
|
||||||
|
*/
|
||||||
|
private function readinessBucket(int $openCount, int $acceptedRiskCount, int $governanceWarningCount, array $limitationFlags): string
|
||||||
|
{
|
||||||
|
if ($openCount > 0 || $governanceWarningCount > 0) {
|
||||||
|
return 'follow_up_required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($acceptedRiskCount > 0 || $limitationFlags !== []) {
|
||||||
|
return 'review_recommended';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'evidence_on_record';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function customerSummary(CanonicalControlDefinition $definition, string $readinessBucket, int $openCount, int $acceptedRiskCount): string
|
||||||
|
{
|
||||||
|
return match ($readinessBucket) {
|
||||||
|
'follow_up_required' => sprintf(
|
||||||
|
'%s needs follow-up because %d open finding(s) remain in the released evidence basis.',
|
||||||
|
$definition->name,
|
||||||
|
$openCount,
|
||||||
|
),
|
||||||
|
'review_recommended' => $acceptedRiskCount > 0
|
||||||
|
? sprintf('%s has evidence on record with accepted-risk context that should be reviewed before relying on the interpretation.', $definition->name)
|
||||||
|
: sprintf('%s has evidence on record, with limitations that should be reviewed before relying on the interpretation.', $definition->name),
|
||||||
|
default => sprintf('%s has evidence on record in this released review.', $definition->name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evidenceBasisSummary(int $signalCount, int $openCount, int $acceptedRiskCount): string
|
||||||
|
{
|
||||||
|
$parts = [
|
||||||
|
sprintf('%d evidence signal(s) reference this control.', $signalCount),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($openCount > 0) {
|
||||||
|
$parts[] = sprintf('%d open finding(s) still need follow-up.', $openCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($acceptedRiskCount > 0) {
|
||||||
|
$parts[] = sprintf('%d accepted-risk finding(s) qualify this view.', $acceptedRiskCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $limitationFlags
|
||||||
|
*/
|
||||||
|
private function recommendedNextAction(string $readinessBucket, int $acceptedRiskCount, array $limitationFlags): string
|
||||||
|
{
|
||||||
|
if ($readinessBucket === 'follow_up_required') {
|
||||||
|
return 'Review the surfaced findings with the tenant and agree ownership plus follow-up timing.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($acceptedRiskCount > 0) {
|
||||||
|
return 'Review the accepted-risk owner and next review date before customer delivery.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($limitationFlags !== []) {
|
||||||
|
return 'Confirm the evidence basis and limitations before using this control as customer-facing readiness support.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Keep this evidence on record and revisit it during the normal review cadence.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $control
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function controlExplanation(array $control, EvidenceSnapshot $snapshot): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => $control['control_name'],
|
||||||
|
'control_key' => $control['control_key'],
|
||||||
|
'control_name' => $control['control_name'],
|
||||||
|
'readiness_bucket' => $control['readiness_bucket'],
|
||||||
|
'readiness_label' => $control['readiness_label'],
|
||||||
|
'limitation_flags' => $control['limitation_flags'],
|
||||||
|
'limitation_labels' => $control['limitation_labels'],
|
||||||
|
'customer_summary' => $control['customer_summary'],
|
||||||
|
'evidence_basis_summary' => $control['evidence_basis_summary'],
|
||||||
|
'accepted_risk_summary' => $control['accepted_risk_summary'],
|
||||||
|
'explanation_text' => $control['customer_summary'],
|
||||||
|
'evidence_basis_items' => array_values(array_filter([
|
||||||
|
$control['evidence_basis_summary'],
|
||||||
|
$control['accepted_risk_summary'],
|
||||||
|
])),
|
||||||
|
'accepted_risk_context' => $control['accepted_risk_summary'],
|
||||||
|
'recommended_next_action' => $control['recommended_next_action'],
|
||||||
|
'proof_access_state' => $this->proofAccessState($snapshot),
|
||||||
|
'supporting_finding_ids' => $control['supporting_finding_ids'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $controlSummaries
|
||||||
|
* @param list<string> $globalLimitations
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function sectionNextActions(array $controlSummaries, array $globalLimitations): array
|
||||||
|
{
|
||||||
|
if ($controlSummaries === []) {
|
||||||
|
return ['Review unmapped evidence before using this review for customer-facing readiness discussions.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions = collect($controlSummaries)
|
||||||
|
->pluck('recommended_next_action')
|
||||||
|
->filter(static fn (mixed $action): bool => is_string($action) && trim($action) !== '')
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if (in_array('unmapped', $globalLimitations, true)) {
|
||||||
|
$actions[] = 'Treat this review as partial until unmapped evidence can be interpreted.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($actions));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $controlSummaries
|
||||||
|
* @param list<string> $snapshotLimitations
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function globalLimitations(array $controlSummaries, array $snapshotLimitations, bool $noMappedControls, int $unresolvedEntryCount): array
|
||||||
|
{
|
||||||
|
$limitations = $snapshotLimitations;
|
||||||
|
|
||||||
|
if ($noMappedControls) {
|
||||||
|
$limitations[] = 'unmapped';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($unresolvedEntryCount > 0) {
|
||||||
|
$limitations[] = 'partial_mapping';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($controlSummaries as $control) {
|
||||||
|
foreach (Arr::wrap($control['limitation_flags'] ?? []) as $limitation) {
|
||||||
|
if (is_string($limitation) && trim($limitation) !== '') {
|
||||||
|
$limitations[] = $limitation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($limitations));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $controlSummaries
|
||||||
|
* @param list<string> $globalLimitations
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function limitationCounts(array $controlSummaries, array $globalLimitations): array
|
||||||
|
{
|
||||||
|
$counts = collect($controlSummaries)
|
||||||
|
->flatMap(static fn (array $control): array => Arr::wrap($control['limitation_flags'] ?? []))
|
||||||
|
->filter(static fn (mixed $limitation): bool => is_string($limitation) && trim($limitation) !== '')
|
||||||
|
->countBy()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
foreach ($globalLimitations as $limitation) {
|
||||||
|
$counts[$limitation] = max((int) ($counts[$limitation] ?? 0), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($counts);
|
||||||
|
|
||||||
|
return array_map('intval', $counts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function snapshotLimitations(EvidenceSnapshot $snapshot, ?EvidenceSnapshotItem $findingsItem, int $unresolvedEntryCount): array
|
||||||
|
{
|
||||||
|
$limitations = [];
|
||||||
|
$state = (string) ($findingsItem?->state ?? $snapshot->completeness_state);
|
||||||
|
|
||||||
|
if ($state === TenantReviewCompletenessState::Stale->value || (string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
||||||
|
$limitations[] = 'stale_evidence';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($state, [TenantReviewCompletenessState::Partial->value, TenantReviewCompletenessState::Missing->value], true)) {
|
||||||
|
$limitations[] = 'partial_mapping';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($unresolvedEntryCount > 0) {
|
||||||
|
$limitations[] = 'partial_mapping';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $snapshot->exists || $snapshot->generated_at === null) {
|
||||||
|
$limitations[] = 'supporting_evidence_unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($limitations));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $snapshotLimitations
|
||||||
|
*/
|
||||||
|
private function sectionCompleteness(?EvidenceSnapshotItem $findingsItem, bool $noMappedControls, array $snapshotLimitations): string
|
||||||
|
{
|
||||||
|
if (! $findingsItem instanceof EvidenceSnapshotItem) {
|
||||||
|
return TenantReviewCompletenessState::Missing->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('stale_evidence', $snapshotLimitations, true)) {
|
||||||
|
return TenantReviewCompletenessState::Stale->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($noMappedControls || in_array('partial_mapping', $snapshotLimitations, true)) {
|
||||||
|
return TenantReviewCompletenessState::Partial->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantReviewCompletenessState::tryFrom((string) $findingsItem->state)?->value
|
||||||
|
?? TenantReviewCompletenessState::Missing->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function proofAccessState(EvidenceSnapshot $snapshot): string
|
||||||
|
{
|
||||||
|
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
||||||
|
return 'expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $snapshot->exists || $snapshot->generated_at === null) {
|
||||||
|
return 'unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'available';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sourceFingerprint(?EvidenceSnapshotItem $item): ?string
|
||||||
|
{
|
||||||
|
$fingerprint = $item?->source_fingerprint;
|
||||||
|
|
||||||
|
return is_string($fingerprint) && $fingerprint !== '' ? $fingerprint : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $entry
|
||||||
|
*/
|
||||||
|
private static function hasGovernanceWarning(array $entry): bool
|
||||||
|
{
|
||||||
|
if (is_string($entry['governance_warning'] ?? null) && trim((string) $entry['governance_warning']) !== '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array((string) ($entry['governance_state'] ?? ''), [
|
||||||
|
'expired_exception',
|
||||||
|
'revoked_exception',
|
||||||
|
'rejected_exception',
|
||||||
|
'risk_accepted_without_valid_exception',
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function readinessLabel(string $bucket): string
|
||||||
|
{
|
||||||
|
return match ($bucket) {
|
||||||
|
'follow_up_required' => 'Follow-up required',
|
||||||
|
'review_recommended' => 'Review recommended',
|
||||||
|
'evidence_on_record' => 'Evidence on record',
|
||||||
|
default => Str::headline($bucket),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function limitationLabel(string $flag): string
|
||||||
|
{
|
||||||
|
return match ($flag) {
|
||||||
|
'accepted_risk_influenced' => 'Accepted risk influences this view',
|
||||||
|
'partial_mapping' => 'Partial evidence mapping',
|
||||||
|
'stale_evidence' => 'Evidence freshness needs review',
|
||||||
|
'supporting_evidence_unavailable' => 'Supporting evidence unavailable',
|
||||||
|
'unmapped' => 'No mapped control coverage',
|
||||||
|
default => Str::headline($flag),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -141,7 +141,7 @@ public function supportsFilters(string $domainKey, string $subjectClass): bool
|
|||||||
public function groupLabel(string $domainKey, string $subjectClass): string
|
public function groupLabel(string $domainKey, string $subjectClass): string
|
||||||
{
|
{
|
||||||
return match ([trim($domainKey), trim($subjectClass)]) {
|
return match ([trim($domainKey), trim($subjectClass)]) {
|
||||||
[GovernanceDomainKey::Intune->value, GovernanceSubjectClass::Policy->value] => 'Intune policies',
|
[GovernanceDomainKey::Intune->value, GovernanceSubjectClass::Policy->value] => __('localization.policy.taxonomy.policies'),
|
||||||
[GovernanceDomainKey::PlatformFoundation->value, GovernanceSubjectClass::ConfigurationResource->value] => 'Platform foundation configuration resources',
|
[GovernanceDomainKey::PlatformFoundation->value, GovernanceSubjectClass::ConfigurationResource->value] => 'Platform foundation configuration resources',
|
||||||
[GovernanceDomainKey::Entra->value, GovernanceSubjectClass::Control->value] => 'Entra controls',
|
[GovernanceDomainKey::Entra->value, GovernanceSubjectClass::Control->value] => 'Entra controls',
|
||||||
default => trim($domainKey).' / '.trim($subjectClass),
|
default => trim($domainKey).' / '.trim($subjectClass),
|
||||||
|
|||||||
@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\GovernanceDecisions;
|
||||||
|
|
||||||
|
use App\Models\FindingException;
|
||||||
|
use App\Models\FindingExceptionDecision;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
final readonly class GovernanceDecisionRegisterBuilder
|
||||||
|
{
|
||||||
|
private const int RECENTLY_CLOSED_DAYS = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const array TERMINAL_STATUSES = [
|
||||||
|
FindingException::STATUS_REJECTED,
|
||||||
|
FindingException::STATUS_REVOKED,
|
||||||
|
FindingException::STATUS_SUPERSEDED,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, Tenant> $visibleTenants
|
||||||
|
* @return array{
|
||||||
|
* rows: list<array<string, mixed>>,
|
||||||
|
* counts: array{open: int, recently_closed: int},
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function build(Workspace $workspace, array $visibleTenants, string $registerState = 'open'): array
|
||||||
|
{
|
||||||
|
$visibleTenantIds = array_values(array_map(
|
||||||
|
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||||
|
$visibleTenants,
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($visibleTenantIds === []) {
|
||||||
|
return [
|
||||||
|
'rows' => [],
|
||||||
|
'counts' => [
|
||||||
|
'open' => 0,
|
||||||
|
'recently_closed' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = FindingException::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->whereIn('tenant_id', $visibleTenantIds)
|
||||||
|
->with(['tenant:id,name', 'owner:id,name', 'currentDecision'])
|
||||||
|
->get()
|
||||||
|
->map(fn (FindingException $exception): ?array => $this->buildRow($exception))
|
||||||
|
->filter()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
/** @var Collection<int, array<string, mixed>> $openRows */
|
||||||
|
$openRows = $rows
|
||||||
|
->where('register_state', 'open')
|
||||||
|
->sortBy([
|
||||||
|
['due_at', 'asc'],
|
||||||
|
['exception_id', 'asc'],
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
|
||||||
|
/** @var Collection<int, array<string, mixed>> $recentlyClosedRows */
|
||||||
|
$recentlyClosedRows = $rows
|
||||||
|
->where('register_state', 'recently_closed')
|
||||||
|
->sortByDesc('decision_at')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rows' => match ($registerState) {
|
||||||
|
'recently_closed' => $recentlyClosedRows->all(),
|
||||||
|
default => $openRows->all(),
|
||||||
|
},
|
||||||
|
'counts' => [
|
||||||
|
'open' => $openRows->count(),
|
||||||
|
'recently_closed' => $recentlyClosedRows->count(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function buildRow(FindingException $exception): ?array
|
||||||
|
{
|
||||||
|
$currentDecision = $exception->currentDecision;
|
||||||
|
|
||||||
|
if (! $currentDecision instanceof FindingExceptionDecision) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$registerState = $this->resolveRegisterState($exception, $currentDecision);
|
||||||
|
|
||||||
|
if ($registerState === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'exception_id' => (int) $exception->getKey(),
|
||||||
|
'register_state' => $registerState,
|
||||||
|
'tenant_name' => $exception->tenant?->name,
|
||||||
|
'owner_name' => $exception->owner?->name,
|
||||||
|
'status' => (string) $exception->status,
|
||||||
|
'current_validity_state' => (string) $exception->current_validity_state,
|
||||||
|
'next_action_label' => $registerState === 'open'
|
||||||
|
? $this->resolveNextActionLabel($exception, $currentDecision)
|
||||||
|
: 'Decision closed',
|
||||||
|
'closure_reason' => $registerState === 'recently_closed'
|
||||||
|
? (string) $currentDecision->reason
|
||||||
|
: null,
|
||||||
|
'due_at' => $exception->review_due_at ?? $exception->expires_at,
|
||||||
|
'decision_at' => $currentDecision->decided_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveRegisterState(FindingException $exception, FindingExceptionDecision $currentDecision): ?string
|
||||||
|
{
|
||||||
|
$status = (string) $exception->status;
|
||||||
|
|
||||||
|
if (in_array($status, self::TERMINAL_STATUSES, true)) {
|
||||||
|
return $this->isRecentlyClosed($currentDecision->decided_at)
|
||||||
|
? 'recently_closed'
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'open';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveNextActionLabel(FindingException $exception, FindingExceptionDecision $currentDecision): string
|
||||||
|
{
|
||||||
|
if ($exception->isPendingRenewal() || $currentDecision->decision_type === FindingExceptionDecision::TYPE_RENEWAL_REQUESTED) {
|
||||||
|
return 'Review renewal';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($exception->isPending()) {
|
||||||
|
return 'Review approval';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Review follow-up';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isRecentlyClosed(?CarbonInterface $decidedAt): bool
|
||||||
|
{
|
||||||
|
if (! $decidedAt instanceof CarbonInterface) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decidedAt->greaterThanOrEqualTo(now()->startOfDay()->subDays(self::RECENTLY_CLOSED_DAYS));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -84,6 +84,20 @@ public static function forGovernanceInbox(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function forDecisionRegister(
|
||||||
|
string $canonicalRouteName,
|
||||||
|
?int $tenantId,
|
||||||
|
string $backLinkUrl,
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
sourceSurface: 'governance.decision_register',
|
||||||
|
canonicalRouteName: $canonicalRouteName,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
backLinkLabel: 'Back to decision register',
|
||||||
|
backLinkUrl: $backLinkUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static function forTenantRegistry(string $backLinkUrl, ?int $tenantId = null): self
|
public static function forTenantRegistry(string $backLinkUrl, ?int $tenantId = null): self
|
||||||
{
|
{
|
||||||
return new self(
|
return new self(
|
||||||
|
|||||||
@ -49,6 +49,18 @@ public function entryLabel(string $relationKey): string
|
|||||||
return OperationRunLinks::singularLabel();
|
return OperationRunLinks::singularLabel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($relationKey === 'current_policy_version') {
|
||||||
|
return __('localization.policy.versions.related_entry_current_policy_version');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($relationKey === 'parent_policy') {
|
||||||
|
return __('localization.policy.versions.related_entry_policy');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($relationKey === 'policy_version') {
|
||||||
|
return __('localization.policy.versions.related_entry_policy_version');
|
||||||
|
}
|
||||||
|
|
||||||
return self::ENTRY_LABELS[$relationKey] ?? Str::headline(str_replace('_', ' ', $relationKey));
|
return self::ENTRY_LABELS[$relationKey] ?? Str::headline(str_replace('_', ' ', $relationKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,6 +74,14 @@ public function actionLabel(string $relationKey): string
|
|||||||
return OperationRunLinks::openLabel();
|
return OperationRunLinks::openLabel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($relationKey === 'parent_policy') {
|
||||||
|
return __('localization.policy.versions.related_action_view_policy');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($relationKey, ['current_policy_version', 'policy_version'], true)) {
|
||||||
|
return __('localization.policy.versions.related_action_view_policy_version');
|
||||||
|
}
|
||||||
|
|
||||||
return self::ACTION_LABELS[$relationKey] ?? 'Open '.Str::headline(str_replace('_', ' ', $relationKey));
|
return self::ACTION_LABELS[$relationKey] ?? 'Open '.Str::headline(str_replace('_', ' ', $relationKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -257,6 +257,7 @@ private static function canonicalDefinitions(): array
|
|||||||
'backup.schedule.retention' => new CanonicalOperationType('backup.schedule.retention', 'intune', null, 'Backup schedule retention'),
|
'backup.schedule.retention' => new CanonicalOperationType('backup.schedule.retention', 'intune', null, 'Backup schedule retention'),
|
||||||
'backup.schedule.purge' => new CanonicalOperationType('backup.schedule.purge', 'intune', null, 'Backup schedule purge'),
|
'backup.schedule.purge' => new CanonicalOperationType('backup.schedule.purge', 'intune', null, 'Backup schedule purge'),
|
||||||
'restore.execute' => new CanonicalOperationType('restore.execute', 'intune', null, 'Restore execution'),
|
'restore.execute' => new CanonicalOperationType('restore.execute', 'intune', null, 'Restore execution'),
|
||||||
|
'promotion.execute' => new CanonicalOperationType('promotion.execute', 'platform_foundation', null, 'Promotion execution', true, 120),
|
||||||
'assignments.fetch' => new CanonicalOperationType('assignments.fetch', 'intune', null, 'Assignment fetch', false, 60),
|
'assignments.fetch' => new CanonicalOperationType('assignments.fetch', 'intune', null, 'Assignment fetch', false, 60),
|
||||||
'assignments.restore' => new CanonicalOperationType('assignments.restore', 'intune', null, 'Assignment restore', false, 60),
|
'assignments.restore' => new CanonicalOperationType('assignments.restore', 'intune', null, 'Assignment restore', false, 60),
|
||||||
'ops.reconcile_adapter_runs' => new CanonicalOperationType('ops.reconcile_adapter_runs', 'platform_foundation', null, 'Reconcile adapter runs', false, 120),
|
'ops.reconcile_adapter_runs' => new CanonicalOperationType('ops.reconcile_adapter_runs', 'platform_foundation', null, 'Reconcile adapter runs', false, 120),
|
||||||
@ -315,6 +316,7 @@ private static function operationAliases(): array
|
|||||||
new OperationTypeAlias('backup.schedule.purge', 'backup.schedule.purge', 'canonical', true),
|
new OperationTypeAlias('backup.schedule.purge', 'backup.schedule.purge', 'canonical', true),
|
||||||
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
||||||
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
|
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
|
||||||
|
new OperationTypeAlias('promotion.execute', 'promotion.execute', 'canonical', true),
|
||||||
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
|
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
|
||||||
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
|
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
|
||||||
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
|
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
|
||||||
|
|||||||
@ -15,6 +15,7 @@ enum OperationRunType: string
|
|||||||
case BackupSchedulePurge = 'backup.schedule.purge';
|
case BackupSchedulePurge = 'backup.schedule.purge';
|
||||||
case DirectoryRoleDefinitionsSync = 'directory.role_definitions.sync';
|
case DirectoryRoleDefinitionsSync = 'directory.role_definitions.sync';
|
||||||
case RestoreExecute = 'restore.execute';
|
case RestoreExecute = 'restore.execute';
|
||||||
|
case PromotionExecute = 'promotion.execute';
|
||||||
case EntraAdminRolesScan = 'entra.admin_roles.scan';
|
case EntraAdminRolesScan = 'entra.admin_roles.scan';
|
||||||
case ReviewPackGenerate = 'tenant.review_pack.generate';
|
case ReviewPackGenerate = 'tenant.review_pack.generate';
|
||||||
case TenantReviewCompose = 'tenant.review.compose';
|
case TenantReviewCompose = 'tenant.review.compose';
|
||||||
|
|||||||
@ -17,6 +17,13 @@ final class OperationalControlCatalog
|
|||||||
'operation_types' => ['restore.execute'],
|
'operation_types' => ['restore.execute'],
|
||||||
'affected_surfaces' => ['tenant.restore_runs.create'],
|
'affected_surfaces' => ['tenant.restore_runs.create'],
|
||||||
],
|
],
|
||||||
|
'promotion.execute' => [
|
||||||
|
'key' => 'promotion.execute',
|
||||||
|
'label' => 'Promotion execution',
|
||||||
|
'supported_scopes' => ['global', 'workspace'],
|
||||||
|
'operation_types' => ['promotion.execute'],
|
||||||
|
'affected_surfaces' => ['admin.cross_tenant_compare.execute'],
|
||||||
|
],
|
||||||
'ai.execution' => [
|
'ai.execution' => [
|
||||||
'key' => 'ai.execution',
|
'key' => 'ai.execution',
|
||||||
'label' => 'AI execution',
|
'label' => 'AI execution',
|
||||||
|
|||||||
@ -25,7 +25,7 @@ public function requiredCapabilityForType(string $operationType): ?string
|
|||||||
'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
'directory.groups.sync' => Capabilities::TENANT_SYNC,
|
'directory.groups.sync' => Capabilities::TENANT_SYNC,
|
||||||
'backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
'backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
||||||
'restore.execute' => Capabilities::TENANT_MANAGE,
|
'restore.execute', 'promotion.execute' => Capabilities::TENANT_MANAGE,
|
||||||
'directory.role_definitions.sync' => Capabilities::TENANT_MANAGE,
|
'directory.role_definitions.sync' => Capabilities::TENANT_MANAGE,
|
||||||
'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW,
|
'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW,
|
||||||
'tenant.review.compose' => Capabilities::TENANT_REVIEW_VIEW,
|
'tenant.review.compose' => Capabilities::TENANT_REVIEW_VIEW,
|
||||||
@ -51,7 +51,7 @@ public function requiredExecutionCapabilityForType(string $operationType): ?stri
|
|||||||
'provider.connection.check', 'inventory.sync', 'compliance.snapshot' => Capabilities::PROVIDER_RUN,
|
'provider.connection.check', 'inventory.sync', 'compliance.snapshot' => Capabilities::PROVIDER_RUN,
|
||||||
'policy.sync', 'tenant.sync' => Capabilities::TENANT_SYNC,
|
'policy.sync', 'tenant.sync' => Capabilities::TENANT_SYNC,
|
||||||
'policy.delete' => Capabilities::TENANT_MANAGE,
|
'policy.delete' => Capabilities::TENANT_MANAGE,
|
||||||
'assignments.restore', 'restore.execute' => Capabilities::TENANT_MANAGE,
|
'assignments.restore', 'restore.execute', 'promotion.execute' => Capabilities::TENANT_MANAGE,
|
||||||
'tenant.review.compose' => Capabilities::TENANT_REVIEW_MANAGE,
|
'tenant.review.compose' => Capabilities::TENANT_REVIEW_MANAGE,
|
||||||
default => $this->requiredCapabilityForType($operationType),
|
default => $this->requiredCapabilityForType($operationType),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -23,6 +23,7 @@ public function __construct(
|
|||||||
public ?User $initiator,
|
public ?User $initiator,
|
||||||
public ExecutionAuthorityMode $authorityMode,
|
public ExecutionAuthorityMode $authorityMode,
|
||||||
public ?string $requiredCapability,
|
public ?string $requiredCapability,
|
||||||
|
public ?string $workspaceRequiredCapability,
|
||||||
public ?int $providerConnectionId,
|
public ?int $providerConnectionId,
|
||||||
public array $targetScope,
|
public array $targetScope,
|
||||||
public array $prerequisiteClasses = [],
|
public array $prerequisiteClasses = [],
|
||||||
|
|||||||
@ -6,9 +6,106 @@
|
|||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
final class ActiveRuns
|
final class ActiveRuns
|
||||||
{
|
{
|
||||||
|
private const int TERMINAL_SUCCESS_GRACE_SECONDS = 30;
|
||||||
|
|
||||||
|
public static function queryForTenantId(?int $tenantId): Builder
|
||||||
|
{
|
||||||
|
return OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->active()
|
||||||
|
->orderByDesc('created_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function shellVisibleQueryForTenantId(?int $tenantId): Builder
|
||||||
|
{
|
||||||
|
return OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$query
|
||||||
|
->where(function (Builder $activeQuery): void {
|
||||||
|
$activeQuery->active();
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $successQuery): void {
|
||||||
|
$successQuery
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where('outcome', OperationRunOutcome::Succeeded->value)
|
||||||
|
->whereNotNull('completed_at')
|
||||||
|
->where('completed_at', '>=', now()->subSeconds(self::TERMINAL_SUCCESS_GRACE_SECONDS));
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $followUpQuery): void {
|
||||||
|
$followUpQuery->terminalFollowUp();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->orderByRaw(
|
||||||
|
'case when status in (?, ?) then 0 when status = ? and outcome in (?, ?, ?) then 1 when status = ? and outcome = ? then 2 else 3 end',
|
||||||
|
[
|
||||||
|
OperationRunStatus::Queued->value,
|
||||||
|
OperationRunStatus::Running->value,
|
||||||
|
OperationRunStatus::Completed->value,
|
||||||
|
OperationRunOutcome::Blocked->value,
|
||||||
|
OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
OperationRunOutcome::Failed->value,
|
||||||
|
OperationRunStatus::Completed->value,
|
||||||
|
OperationRunOutcome::Succeeded->value,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
->orderByRaw('coalesce(completed_at, started_at, created_at) desc')
|
||||||
|
->orderByDesc('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, OperationRun>
|
||||||
|
*/
|
||||||
|
public static function visibleForTenantId(?int $tenantId, int $limit = 3): Collection
|
||||||
|
{
|
||||||
|
if (! is_int($tenantId) || $tenantId <= 0) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::queryForTenantId($tenantId)
|
||||||
|
->limit(max(1, $limit))
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, OperationRun>
|
||||||
|
*/
|
||||||
|
public static function shellVisibleForTenantId(?int $tenantId, int $limit = 3): Collection
|
||||||
|
{
|
||||||
|
if (! is_int($tenantId) || $tenantId <= 0) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::shellVisibleQueryForTenantId($tenantId)
|
||||||
|
->limit(max(1, $limit))
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function countForTenantId(?int $tenantId): int
|
||||||
|
{
|
||||||
|
if (! is_int($tenantId) || $tenantId <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::queryForTenantId($tenantId)->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function shellVisibleCountForTenantId(?int $tenantId): int
|
||||||
|
{
|
||||||
|
if (! is_int($tenantId) || $tenantId <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::shellVisibleQueryForTenantId($tenantId)->count();
|
||||||
|
}
|
||||||
|
|
||||||
public static function existForTenant(Tenant $tenant): bool
|
public static function existForTenant(Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
return self::existForTenantId((int) $tenant->getKey());
|
return self::existForTenantId((int) $tenant->getKey());
|
||||||
|
|||||||
414
apps/platform/app/Support/OpsUx/OperationRunProgressContract.php
Normal file
414
apps/platform/app/Support/OpsUx/OperationRunProgressContract.php
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\OpsUx;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
|
||||||
|
final class OperationRunProgressContract
|
||||||
|
{
|
||||||
|
public const string NONE = 'none';
|
||||||
|
|
||||||
|
public const string ACTIVITY = 'activity';
|
||||||
|
|
||||||
|
public const string COUNTED = 'counted';
|
||||||
|
|
||||||
|
public const string PHASED = 'phased';
|
||||||
|
|
||||||
|
public const string COMPOSITE = 'composite';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* capability: string,
|
||||||
|
* display: string,
|
||||||
|
* label: ?string,
|
||||||
|
* processed: ?int,
|
||||||
|
* total: ?int,
|
||||||
|
* percent: ?int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public static function forRun(OperationRun $run): array
|
||||||
|
{
|
||||||
|
$summaryCounts = SummaryCountsNormalizer::normalize(
|
||||||
|
is_array($run->summary_counts) ? $run->summary_counts : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
$capability = self::capabilityForRun($run, $summaryCounts, $context);
|
||||||
|
|
||||||
|
return match ($capability) {
|
||||||
|
self::COUNTED => self::countedModel($summaryCounts),
|
||||||
|
self::PHASED => self::phasedModel($run, $context),
|
||||||
|
self::COMPOSITE => self::compositeModel($run, $summaryCounts, $context),
|
||||||
|
self::ACTIVITY => self::indeterminateModel(
|
||||||
|
self::ACTIVITY,
|
||||||
|
(string) $run->status === OperationRunStatus::Queued->value
|
||||||
|
? 'Waiting for worker.'
|
||||||
|
: 'Progress details pending.',
|
||||||
|
),
|
||||||
|
default => self::noneModel(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $summaryCounts
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private static function capabilityForRun(OperationRun $run, array $summaryCounts, array $context): string
|
||||||
|
{
|
||||||
|
if (! $run->isCurrentlyActive()) {
|
||||||
|
return self::NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $run->status === OperationRunStatus::Queued->value) {
|
||||||
|
return self::ACTIVITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::hasPhasedHint($context)) {
|
||||||
|
return self::PHASED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::hasCompositeHint($summaryCounts, $context)) {
|
||||||
|
return self::COMPOSITE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::hasCountedHint($summaryCounts)) {
|
||||||
|
return self::COUNTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::ACTIVITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $summaryCounts
|
||||||
|
*/
|
||||||
|
private static function hasCountedHint(array $summaryCounts): bool
|
||||||
|
{
|
||||||
|
$total = $summaryCounts['total'] ?? null;
|
||||||
|
$processed = $summaryCounts['processed'] ?? null;
|
||||||
|
|
||||||
|
return is_int($total)
|
||||||
|
&& $total > 0
|
||||||
|
&& is_int($processed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $summaryCounts
|
||||||
|
* @return array{
|
||||||
|
* capability: string,
|
||||||
|
* display: string,
|
||||||
|
* label: string,
|
||||||
|
* processed: int,
|
||||||
|
* total: int,
|
||||||
|
* percent: int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function countedModel(array $summaryCounts): array
|
||||||
|
{
|
||||||
|
$total = max(1, (int) ($summaryCounts['total'] ?? 0));
|
||||||
|
$processed = min(max(0, (int) ($summaryCounts['processed'] ?? 0)), $total);
|
||||||
|
$percent = max(0, min(100, (int) round(($processed / $total) * 100)));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'capability' => self::COUNTED,
|
||||||
|
'display' => self::COUNTED,
|
||||||
|
'label' => sprintf('%d / %d processed (%d%%)', $processed, $total, $percent),
|
||||||
|
'processed' => $processed,
|
||||||
|
'total' => $total,
|
||||||
|
'percent' => $percent,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array{
|
||||||
|
* capability: string,
|
||||||
|
* display: string,
|
||||||
|
* label: string,
|
||||||
|
* processed: null,
|
||||||
|
* total: null,
|
||||||
|
* percent: null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function phasedModel(OperationRun $run, array $context): array
|
||||||
|
{
|
||||||
|
$phase = self::phaseProgressMetadata($context);
|
||||||
|
|
||||||
|
if ($phase !== null) {
|
||||||
|
return self::indeterminateModel(self::PHASED, $phase['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::indeterminateModel(
|
||||||
|
self::PHASED,
|
||||||
|
self::legacyPhasedLabel($run, $context) ?? 'Phase progress pending.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $summaryCounts
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array{
|
||||||
|
* capability: string,
|
||||||
|
* display: string,
|
||||||
|
* label: string,
|
||||||
|
* processed: null,
|
||||||
|
* total: null,
|
||||||
|
* percent: null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function compositeModel(OperationRun $run, array $summaryCounts, array $context): array
|
||||||
|
{
|
||||||
|
$label = self::explicitCompositeLabel($context)
|
||||||
|
?? self::legacyCompositeLabel($run, $summaryCounts, $context)
|
||||||
|
?? 'Composite progress pending.';
|
||||||
|
|
||||||
|
return self::indeterminateModel(self::COMPOSITE, $label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* capability: string,
|
||||||
|
* display: string,
|
||||||
|
* label: string,
|
||||||
|
* processed: null,
|
||||||
|
* total: null,
|
||||||
|
* percent: null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function indeterminateModel(string $capability, string $label): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'capability' => $capability,
|
||||||
|
'display' => 'indeterminate',
|
||||||
|
'label' => $label,
|
||||||
|
'processed' => null,
|
||||||
|
'total' => null,
|
||||||
|
'percent' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* capability: string,
|
||||||
|
* display: string,
|
||||||
|
* label: null,
|
||||||
|
* processed: null,
|
||||||
|
* total: null,
|
||||||
|
* percent: null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function noneModel(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'capability' => self::NONE,
|
||||||
|
'display' => self::NONE,
|
||||||
|
'label' => null,
|
||||||
|
'processed' => null,
|
||||||
|
'total' => null,
|
||||||
|
'percent' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private static function hasPhasedHint(array $context): bool
|
||||||
|
{
|
||||||
|
if (self::phaseProgressMetadata($context) !== null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['baseline_capture.evidence_capture', 'baseline_compare.evidence_capture'] as $path) {
|
||||||
|
$phaseStats = data_get($context, $path);
|
||||||
|
|
||||||
|
if (is_array($phaseStats) && self::looksLikePhaseStats($phaseStats)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $phaseStats
|
||||||
|
*/
|
||||||
|
private static function looksLikePhaseStats(array $phaseStats): bool
|
||||||
|
{
|
||||||
|
return count(array_intersect(
|
||||||
|
array_keys($phaseStats),
|
||||||
|
['requested', 'succeeded', 'skipped', 'failed', 'throttled'],
|
||||||
|
)) >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $summaryCounts
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private static function hasCompositeHint(array $summaryCounts, array $context): bool
|
||||||
|
{
|
||||||
|
if (self::explicitCompositeLabel($context) !== null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$operationCount = $summaryCounts['operation_count'] ?? null;
|
||||||
|
|
||||||
|
if (is_int($operationCount) && $operationCount > 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['child_run_ids', 'operation_run_ids'] as $path) {
|
||||||
|
$runIds = data_get($context, $path);
|
||||||
|
|
||||||
|
if (! is_array($runIds)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$numericIds = array_filter($runIds, static fn (mixed $runId): bool => is_numeric($runId));
|
||||||
|
|
||||||
|
if (count($numericIds) > 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array{key: string, label: string}|null
|
||||||
|
*/
|
||||||
|
private static function phaseProgressMetadata(array $context): ?array
|
||||||
|
{
|
||||||
|
$phase = data_get($context, 'progress.phase');
|
||||||
|
|
||||||
|
if (! is_array($phase)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = self::cleanString($phase['key'] ?? null);
|
||||||
|
|
||||||
|
if ($key === null || ! in_array($key, ['preparing', 'fetching', 'processing', 'persisting', 'finalizing'], true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = self::cleanString($phase['label'] ?? null) ?? self::defaultPhaseLabel($key);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'key' => $key,
|
||||||
|
'label' => $label,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function defaultPhaseLabel(string $key): string
|
||||||
|
{
|
||||||
|
return match ($key) {
|
||||||
|
'preparing' => 'Preparing work.',
|
||||||
|
'fetching' => 'Collecting required evidence.',
|
||||||
|
'processing' => 'Processing current work.',
|
||||||
|
'persisting' => 'Saving results.',
|
||||||
|
'finalizing' => 'Finalizing operation.',
|
||||||
|
default => 'Phase progress pending.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private static function explicitCompositeLabel(array $context): ?string
|
||||||
|
{
|
||||||
|
return self::cleanString(data_get($context, 'progress.composite.label'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private static function legacyPhasedLabel(OperationRun $run, array $context): ?string
|
||||||
|
{
|
||||||
|
return match ((string) $run->type) {
|
||||||
|
'baseline_capture' => is_array(data_get($context, 'baseline_capture.evidence_capture'))
|
||||||
|
? 'Capturing evidence.'
|
||||||
|
: null,
|
||||||
|
'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_capture'))
|
||||||
|
? 'Refreshing comparison evidence.'
|
||||||
|
: null,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $summaryCounts
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private static function legacyCompositeLabel(OperationRun $run, array $summaryCounts, array $context): ?string
|
||||||
|
{
|
||||||
|
if ((string) $run->type !== 'tenant.review.compose') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$operationCount = self::intOrNull($summaryCounts['operation_count'] ?? data_get($context, 'progress.composite.operation_count'));
|
||||||
|
$failedCount = self::intOrNull(data_get($context, 'progress.composite.failed_count'));
|
||||||
|
$partialCount = self::intOrNull(data_get($context, 'progress.composite.partial_count'));
|
||||||
|
|
||||||
|
$baseLabel = $operationCount !== null && $operationCount > 0
|
||||||
|
? sprintf('Review composition is aggregating %d %s.', $operationCount, $operationCount === 1 ? 'operation' : 'operations')
|
||||||
|
: 'Review composition is aggregating related operations.';
|
||||||
|
|
||||||
|
$attentionLabel = self::compositeAttentionLabel($failedCount, $partialCount);
|
||||||
|
|
||||||
|
return $attentionLabel === null
|
||||||
|
? $baseLabel
|
||||||
|
: sprintf('%s %s', $baseLabel, $attentionLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function compositeAttentionLabel(?int $failedCount, ?int $partialCount): ?string
|
||||||
|
{
|
||||||
|
$failedCount = $failedCount !== null && $failedCount > 0 ? $failedCount : null;
|
||||||
|
$partialCount = $partialCount !== null && $partialCount > 0 ? $partialCount : null;
|
||||||
|
|
||||||
|
if ($failedCount === null && $partialCount === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failedCount !== null && $partialCount !== null) {
|
||||||
|
return sprintf(
|
||||||
|
'%d %s and %d %s currently need review.',
|
||||||
|
$failedCount,
|
||||||
|
$failedCount === 1 ? 'failed operation' : 'failed operations',
|
||||||
|
$partialCount,
|
||||||
|
$partialCount === 1 ? 'partial operation' : 'partial operations',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failedCount !== null) {
|
||||||
|
return sprintf(
|
||||||
|
'%d %s currently need review.',
|
||||||
|
$failedCount,
|
||||||
|
$failedCount === 1 ? 'failed operation' : 'failed operations',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'%d %s currently need review.',
|
||||||
|
$partialCount,
|
||||||
|
$partialCount === 1 ? 'partial operation' : 'partial operations',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function cleanString(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
return $value === '' ? null : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function intOrNull(mixed $value): ?int
|
||||||
|
{
|
||||||
|
return is_int($value) ? $value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Support\OpsUx;
|
namespace App\Support\OpsUx;
|
||||||
|
|
||||||
|
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||||
use App\Livewire\BulkOperationProgress;
|
use App\Livewire\BulkOperationProgress;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
|
|
||||||
@ -27,5 +28,6 @@ public static function dispatchRunEnqueued(mixed $livewire): void
|
|||||||
// Our progress widget is mounted outside the initiating component's DOM tree,
|
// Our progress widget is mounted outside the initiating component's DOM tree,
|
||||||
// so we target it explicitly to ensure it receives the event immediately.
|
// so we target it explicitly to ensure it receives the event immediately.
|
||||||
$livewire->dispatch(self::RunEnqueued, tenantId: $tenantId)->to(BulkOperationProgress::class);
|
$livewire->dispatch(self::RunEnqueued, tenantId: $tenantId)->to(BulkOperationProgress::class);
|
||||||
|
$livewire->dispatch(self::RunEnqueued, tenantId: $tenantId)->to(InventoryKpiHeader::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,6 +43,34 @@ public static function elapsedHuman(OperationRun $run): string
|
|||||||
return $end->diffForHumans($start, true);
|
return $end->diffForHumans($start, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function elapsedCompact(OperationRun $run): string
|
||||||
|
{
|
||||||
|
$seconds = self::elapsedSeconds($run);
|
||||||
|
|
||||||
|
if (! is_int($seconds) || $seconds <= 0) {
|
||||||
|
return 'now';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($seconds < 60) {
|
||||||
|
return sprintf('%d sec.', $seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($seconds < 3600) {
|
||||||
|
return sprintf('%d min.', (int) round($seconds / 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($seconds < 86400) {
|
||||||
|
return sprintf('%d hr.', (int) round($seconds / 3600));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%d day%s', (int) round($seconds / 86400), $seconds < 172800 ? '' : 's');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function completedRecency(OperationRun $run): string
|
||||||
|
{
|
||||||
|
return $run->completed_at?->diffForHumans() ?? 'just now';
|
||||||
|
}
|
||||||
|
|
||||||
public static function expectedSeconds(OperationRun $run): ?int
|
public static function expectedSeconds(OperationRun $run): ?int
|
||||||
{
|
{
|
||||||
$catalog = OperationCatalog::expectedDurationSeconds((string) $run->type);
|
$catalog = OperationCatalog::expectedDurationSeconds((string) $run->type);
|
||||||
|
|||||||
@ -0,0 +1,271 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\PortfolioCompare;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class CrossTenantPromotionExecutionPlanner
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $preview
|
||||||
|
* @param array<string, mixed> $preflight
|
||||||
|
* @return array{
|
||||||
|
* selection: array<string, mixed>,
|
||||||
|
* summary: array{total: int, ready: int, excluded: int, skipped: int, created: int, updated: int},
|
||||||
|
* items: list<array<string, mixed>>,
|
||||||
|
* excluded: list<array<string, mixed>>,
|
||||||
|
* identity: array<string, mixed>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function build(array $preview, array $preflight): array
|
||||||
|
{
|
||||||
|
$previewSelection = $this->selection($preview);
|
||||||
|
$preflightSelection = $this->selection($preflight);
|
||||||
|
|
||||||
|
if ($previewSelection !== $preflightSelection) {
|
||||||
|
throw new InvalidArgumentException('Promotion preflight is stale. Regenerate the preflight before execution.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
$excluded = $this->excludedSubjects($preflight);
|
||||||
|
|
||||||
|
foreach ($this->readySubjects($preflight) as $subject) {
|
||||||
|
$item = $this->executionItem($subject);
|
||||||
|
|
||||||
|
if ($item === null) {
|
||||||
|
$excluded[] = $this->excludedSubject($subject, 'source_policy_version_missing');
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $this->sortItems($items);
|
||||||
|
$excluded = $this->sortItems($excluded);
|
||||||
|
|
||||||
|
if ($items === []) {
|
||||||
|
throw new DomainException('Promotion preflight has no executable ready subjects.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'total' => count($items) + count($excluded),
|
||||||
|
'ready' => count($items),
|
||||||
|
'excluded' => count($excluded),
|
||||||
|
'skipped' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'skip_aligned')),
|
||||||
|
'created' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'create_missing')),
|
||||||
|
'updated' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'update_existing')),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'selection' => $previewSelection,
|
||||||
|
'summary' => $summary,
|
||||||
|
'items' => $items,
|
||||||
|
'excluded' => $excluded,
|
||||||
|
'identity' => $this->identity($previewSelection, $items),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array{sourceTenantId: ?int, targetTenantId: ?int, policyTypes: list<string>}
|
||||||
|
*/
|
||||||
|
private function selection(array $payload): array
|
||||||
|
{
|
||||||
|
$selection = is_array($payload['selection'] ?? null) ? $payload['selection'] : [];
|
||||||
|
$policyTypes = is_array($selection['policyTypes'] ?? null) ? $selection['policyTypes'] : [];
|
||||||
|
|
||||||
|
$policyTypes = array_values(array_unique(array_filter(array_map(
|
||||||
|
static fn (mixed $value): string => is_string($value) ? trim($value) : '',
|
||||||
|
$policyTypes,
|
||||||
|
), static fn (string $value): bool => $value !== '')));
|
||||||
|
|
||||||
|
sort($policyTypes);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sourceTenantId' => is_numeric($selection['sourceTenantId'] ?? null) ? (int) $selection['sourceTenantId'] : null,
|
||||||
|
'targetTenantId' => is_numeric($selection['targetTenantId'] ?? null) ? (int) $selection['targetTenantId'] : null,
|
||||||
|
'policyTypes' => $policyTypes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $preflight
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function readySubjects(array $preflight): array
|
||||||
|
{
|
||||||
|
$subjects = data_get($preflight, 'buckets.ready', []);
|
||||||
|
|
||||||
|
if (! is_array($subjects)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter($subjects, 'is_array'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $preflight
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function excludedSubjects(array $preflight): array
|
||||||
|
{
|
||||||
|
$excluded = [];
|
||||||
|
|
||||||
|
foreach (['blocked', 'manual_mapping_required'] as $bucket) {
|
||||||
|
$subjects = data_get($preflight, 'buckets.'.$bucket, []);
|
||||||
|
|
||||||
|
if (! is_array($subjects)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($subjects as $subject) {
|
||||||
|
if (! is_array($subject)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$excluded[] = $this->excludedSubject($subject, $bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $excluded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $subject
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function executionItem(array $subject): ?array
|
||||||
|
{
|
||||||
|
$policyVersionId = data_get($subject, 'source.evidence.policyVersionId');
|
||||||
|
|
||||||
|
if (! is_numeric($policyVersionId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = is_string($subject['state'] ?? null) ? (string) $subject['state'] : 'blocked';
|
||||||
|
$action = match ($state) {
|
||||||
|
'match' => 'skip_aligned',
|
||||||
|
'missing' => 'create_missing',
|
||||||
|
default => 'update_existing',
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'policy_type' => $this->stringValue($subject, 'policyType'),
|
||||||
|
'display_name' => $this->stringValue($subject, 'displayName'),
|
||||||
|
'subject_key' => $this->stringValue($subject, 'subjectKey'),
|
||||||
|
'compare_state' => $state,
|
||||||
|
'execution_action' => $action,
|
||||||
|
'readiness_reason_codes' => $this->stringList(data_get($subject, 'preflight.reasonCodes', [])),
|
||||||
|
'source' => [
|
||||||
|
'tenant_id' => $this->intValue(data_get($subject, 'source.tenantId')),
|
||||||
|
'inventory_item_id' => $this->intValue(data_get($subject, 'source.inventoryItemId')),
|
||||||
|
'subject_external_id' => $this->nullableString(data_get($subject, 'source.subjectExternalId')),
|
||||||
|
'policy_version_id' => (int) $policyVersionId,
|
||||||
|
'evidence_hash' => $this->nullableString(data_get($subject, 'source.evidence.hash')),
|
||||||
|
],
|
||||||
|
'target' => [
|
||||||
|
'tenant_id' => $this->intValue(data_get($subject, 'target.tenantId')),
|
||||||
|
'inventory_item_id' => $this->intValue(data_get($subject, 'target.inventoryItemId')),
|
||||||
|
'subject_external_id' => $this->nullableString(data_get($subject, 'target.subjectExternalId')),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $subject
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function excludedSubject(array $subject, string $reason): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'policy_type' => $this->stringValue($subject, 'policyType'),
|
||||||
|
'display_name' => $this->stringValue($subject, 'displayName'),
|
||||||
|
'subject_key' => $this->stringValue($subject, 'subjectKey'),
|
||||||
|
'compare_state' => $this->stringValue($subject, 'state'),
|
||||||
|
'excluded_reason' => $reason,
|
||||||
|
'reason_codes' => $this->stringList(data_get($subject, 'preflight.reasonCodes', [])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $items
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function sortItems(array $items): array
|
||||||
|
{
|
||||||
|
usort($items, static function (array $left, array $right): int {
|
||||||
|
return [
|
||||||
|
(string) ($left['policy_type'] ?? ''),
|
||||||
|
(string) ($left['subject_key'] ?? ''),
|
||||||
|
(string) ($left['display_name'] ?? ''),
|
||||||
|
] <=> [
|
||||||
|
(string) ($right['policy_type'] ?? ''),
|
||||||
|
(string) ($right['subject_key'] ?? ''),
|
||||||
|
(string) ($right['display_name'] ?? ''),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $selection
|
||||||
|
* @param list<array<string, mixed>> $items
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function identity(array $selection, array $items): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'source_tenant_id' => $selection['sourceTenantId'] ?? null,
|
||||||
|
'target_tenant_id' => $selection['targetTenantId'] ?? null,
|
||||||
|
'policy_types' => $selection['policyTypes'] ?? [],
|
||||||
|
'subjects' => array_map(static fn (array $item): array => [
|
||||||
|
'policy_type' => $item['policy_type'] ?? '',
|
||||||
|
'subject_key' => $item['subject_key'] ?? '',
|
||||||
|
'source_policy_version_id' => data_get($item, 'source.policy_version_id'),
|
||||||
|
'source_evidence_hash' => data_get($item, 'source.evidence_hash'),
|
||||||
|
'target_subject_external_id' => data_get($item, 'target.subject_external_id'),
|
||||||
|
'execution_action' => $item['execution_action'] ?? '',
|
||||||
|
], $items),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $subject
|
||||||
|
*/
|
||||||
|
private function stringValue(array $subject, string $key): string
|
||||||
|
{
|
||||||
|
$value = $subject[$key] ?? null;
|
||||||
|
|
||||||
|
return is_string($value) ? $value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableString(mixed $value): ?string
|
||||||
|
{
|
||||||
|
return is_string($value) && trim($value) !== '' ? trim($value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function intValue(mixed $value): ?int
|
||||||
|
{
|
||||||
|
return is_numeric($value) ? (int) $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function stringList(mixed $values): array
|
||||||
|
{
|
||||||
|
if (! is_array($values)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(array_map(
|
||||||
|
static fn (mixed $value): string => is_string($value) ? trim($value) : '',
|
||||||
|
$values,
|
||||||
|
), static fn (string $value): bool => $value !== ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -54,12 +54,12 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
|
|||||||
|
|
||||||
return $this->resolved(
|
return $this->resolved(
|
||||||
descriptor: $descriptor,
|
descriptor: $descriptor,
|
||||||
primaryLabel: (string) ($policy->display_name ?: 'Policy'),
|
primaryLabel: (string) ($policy->display_name ?: __('localization.policy.versions.related_entry_policy')),
|
||||||
secondaryLabel: 'Policy #'.$policy->getKey(),
|
secondaryLabel: __('localization.policy.versions.reference_policy_number', ['id' => $policy->getKey()]),
|
||||||
linkTarget: new ReferenceLinkTarget(
|
linkTarget: new ReferenceLinkTarget(
|
||||||
targetKind: ReferenceClass::Policy->value,
|
targetKind: ReferenceClass::Policy->value,
|
||||||
url: PolicyResource::getUrl('view', ['record' => $policy], tenant: $policy->tenant),
|
url: PolicyResource::getUrl('view', ['record' => $policy], tenant: $policy->tenant),
|
||||||
actionLabel: 'View policy',
|
actionLabel: __('localization.policy.versions.related_action_view_policy'),
|
||||||
contextBadge: 'Tenant',
|
contextBadge: 'Tenant',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -53,7 +53,7 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
|
|||||||
}
|
}
|
||||||
|
|
||||||
$policyName = $version->policy?->display_name;
|
$policyName = $version->policy?->display_name;
|
||||||
$secondary = 'Version '.(string) $version->version_number;
|
$secondary = __('localization.policy.versions.reference_version_number', ['version' => (string) $version->version_number]);
|
||||||
|
|
||||||
if (is_string($version->capture_purpose?->value) && $version->capture_purpose->value !== '') {
|
if (is_string($version->capture_purpose?->value) && $version->capture_purpose->value !== '') {
|
||||||
$secondary .= ' · '.str_replace('_', ' ', $version->capture_purpose->value);
|
$secondary .= ' · '.str_replace('_', ' ', $version->capture_purpose->value);
|
||||||
@ -61,12 +61,12 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
|
|||||||
|
|
||||||
return $this->resolved(
|
return $this->resolved(
|
||||||
descriptor: $descriptor,
|
descriptor: $descriptor,
|
||||||
primaryLabel: is_string($policyName) && trim($policyName) !== '' ? $policyName : 'Policy version',
|
primaryLabel: is_string($policyName) && trim($policyName) !== '' ? $policyName : __('localization.policy.versions.related_entry_policy_version'),
|
||||||
secondaryLabel: $secondary,
|
secondaryLabel: $secondary,
|
||||||
linkTarget: new ReferenceLinkTarget(
|
linkTarget: new ReferenceLinkTarget(
|
||||||
targetKind: ReferenceClass::PolicyVersion->value,
|
targetKind: ReferenceClass::PolicyVersion->value,
|
||||||
url: PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $version->tenant),
|
url: PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $version->tenant),
|
||||||
actionLabel: 'View policy version',
|
actionLabel: __('localization.policy.versions.related_action_view_policy_version'),
|
||||||
contextBadge: 'Tenant',
|
contextBadge: 'Tenant',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\TenantDashboard;
|
||||||
|
|
||||||
|
final readonly class TenantDashboardSummary
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array{workspace:string,tenant:string,provider:?string,providerKey:?string,latestActivity:?string} $context
|
||||||
|
* @param array{status:string,tone:string,headline:string,summary:string} $posture
|
||||||
|
* @param list<array<string, mixed>> $kpis
|
||||||
|
* @param list<array<string, mixed>> $recommendedActions
|
||||||
|
* @param list<array<string, mixed>> $governanceStatus
|
||||||
|
* @param list<array<string, mixed>> $readinessCards
|
||||||
|
* @param list<array<string, mixed>> $recentOperations
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public array $context,
|
||||||
|
public array $posture,
|
||||||
|
public array $kpis,
|
||||||
|
public array $recommendedActions,
|
||||||
|
public array $governanceStatus,
|
||||||
|
public array $readinessCards,
|
||||||
|
public array $recentOperations,
|
||||||
|
public ?string $pollingInterval,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* context: array{workspace:string,tenant:string,provider:?string,providerKey:?string,latestActivity:?string},
|
||||||
|
* posture: array{status:string,tone:string,headline:string,summary:string},
|
||||||
|
* kpis: list<array<string, mixed>>,
|
||||||
|
* recommendedActions: list<array<string, mixed>>,
|
||||||
|
* governanceStatus: list<array<string, mixed>>,
|
||||||
|
* readinessCards: list<array<string, mixed>>,
|
||||||
|
* recentOperations: list<array<string, mixed>>,
|
||||||
|
* pollingInterval: ?string,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'context' => $this->context,
|
||||||
|
'posture' => $this->posture,
|
||||||
|
'kpis' => $this->kpis,
|
||||||
|
'recommendedActions' => $this->recommendedActions,
|
||||||
|
'governanceStatus' => $this->governanceStatus,
|
||||||
|
'readinessCards' => $this->readinessCards,
|
||||||
|
'recentOperations' => $this->recentOperations,
|
||||||
|
'pollingInterval' => $this->pollingInterval,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -34,6 +34,10 @@ public function __construct(
|
|||||||
public array $dimensions = [],
|
public array $dimensions = [],
|
||||||
public ?ArtifactTruthCause $reason = null,
|
public ?ArtifactTruthCause $reason = null,
|
||||||
public ?OperatorExplanationPattern $operatorExplanation = null,
|
public ?OperatorExplanationPattern $operatorExplanation = null,
|
||||||
|
public ?string $displayReference = null,
|
||||||
|
public ?string $integrityAnchor = null,
|
||||||
|
public ?string $lifecycleState = null,
|
||||||
|
public ?string $retentionState = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function primaryDimension(): ?ArtifactTruthDimension
|
public function primaryDimension(): ?ArtifactTruthDimension
|
||||||
@ -87,6 +91,10 @@ public function nextStepText(): string
|
|||||||
* nextActionUrl: ?string,
|
* nextActionUrl: ?string,
|
||||||
* relatedRunId: ?int,
|
* relatedRunId: ?int,
|
||||||
* relatedArtifactUrl: ?string,
|
* relatedArtifactUrl: ?string,
|
||||||
|
* displayReference: ?string,
|
||||||
|
* integrityAnchor: ?string,
|
||||||
|
* lifecycleState: ?string,
|
||||||
|
* retentionState: ?string,
|
||||||
* dimensions: array<int, array{
|
* dimensions: array<int, array{
|
||||||
* axis: string,
|
* axis: string,
|
||||||
* state: string,
|
* state: string,
|
||||||
@ -154,6 +162,10 @@ public function toArray(?CompressedGovernanceOutcome $compressedOutcome = null):
|
|||||||
'nextActionUrl' => $this->nextActionUrl,
|
'nextActionUrl' => $this->nextActionUrl,
|
||||||
'relatedRunId' => $this->relatedRunId,
|
'relatedRunId' => $this->relatedRunId,
|
||||||
'relatedArtifactUrl' => $this->relatedArtifactUrl,
|
'relatedArtifactUrl' => $this->relatedArtifactUrl,
|
||||||
|
'displayReference' => $this->displayReference,
|
||||||
|
'integrityAnchor' => $this->integrityAnchor,
|
||||||
|
'lifecycleState' => $this->lifecycleState,
|
||||||
|
'retentionState' => $this->retentionState,
|
||||||
'dimensions' => array_values(array_map(
|
'dimensions' => array_values(array_map(
|
||||||
static fn (ArtifactTruthDimension $dimension): array => $dimension->toArray(),
|
static fn (ArtifactTruthDimension $dimension): array => $dimension->toArray(),
|
||||||
array_filter(
|
array_filter(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user