Compare commits
2 Commits
dev
...
236-canoni
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ce718ddaf | ||
|
|
18c0d3f49d |
22
.github/agents/copilot-instructions.md
vendored
22
.github/agents/copilot-instructions.md
vendored
@ -250,22 +250,6 @@ ## Active Technologies
|
||||
- Existing PostgreSQL tables only; no new table or schema migration is planned in the mainline slice (235-baseline-capture-truth)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure (236-canonical-control-catalog-foundation)
|
||||
- PostgreSQL for existing downstream governance artifacts plus a product-seeded in-repo canonical control registry; no new DB-backed control authoring table in the first slice (236-canonical-control-catalog-foundation)
|
||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4 (237-provider-boundary-hardening)
|
||||
- Existing PostgreSQL tables such as `provider_connections` and `operation_runs`; one new in-repo config catalog for provider-boundary ownership; no new database tables (237-provider-boundary-hardening)
|
||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4 (238-provider-identity-target-scope)
|
||||
- Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope)
|
||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 (239-canonical-operation-type-source-of-truth)
|
||||
- PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables (239-canonical-operation-type-source-of-truth)
|
||||
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers (240-tenant-onboarding-readiness)
|
||||
- PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness)
|
||||
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` (241-support-diagnostic-pack)
|
||||
- PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack)
|
||||
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services (249-customer-review-workspace)
|
||||
- PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page (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)
|
||||
- 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.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -300,9 +284,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## 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`
|
||||
- 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
|
||||
- 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
|
||||
- 236-canonical-control-catalog-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure
|
||||
- 235-baseline-capture-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces
|
||||
- 234-dead-transitional-residue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
### Pre-production compatibility check
|
||||
|
||||
625
.github/skills/platform-feature-finish/SKILL.md
vendored
625
.github/skills/platform-feature-finish/SKILL.md
vendored
@ -1,625 +0,0 @@
|
||||
|
||||
|
||||
---
|
||||
name: platform-feature-finish
|
||||
description: Commit, push, create a Gitea PR from a TenantPilot platform feature branch into platform-dev, and optionally refresh the platform-dev to dev integration PR by rebase.
|
||||
---
|
||||
|
||||
# Skill: platform-feature-finish
|
||||
|
||||
## Purpose
|
||||
|
||||
Automate the TenantPilot platform feature completion workflow.
|
||||
|
||||
Trigger this skill when the user says something like:
|
||||
|
||||
- "alles committen pushen und PR gegen platform-dev"
|
||||
- "feature fertig, bitte PR erstellen"
|
||||
- "platform feature abschließen"
|
||||
- "commit push PR mit Gitea MCP"
|
||||
- "mach PR gegen platform-dev"
|
||||
- "finish platform feature"
|
||||
- "platform-dev nach dev vorbereiten"
|
||||
- "platform-dev PR aktualisieren"
|
||||
- "out-of-date mit dev beheben"
|
||||
- "integration PR refresh"
|
||||
- "platform-dev auf dev rebasen"
|
||||
|
||||
This skill handles:
|
||||
|
||||
1. Validate current Git branch
|
||||
2. Commit all feature changes
|
||||
3. Push current feature branch
|
||||
4. Create a Gitea pull request into `platform-dev`
|
||||
5. Refresh the `platform-dev` → `dev` integration PR when explicitly requested
|
||||
6. Report the PR link and next integration step
|
||||
|
||||
---
|
||||
|
||||
## Branch Model
|
||||
|
||||
TenantPilot uses area branches:
|
||||
|
||||
```text
|
||||
dev = shared integration branch
|
||||
platform-dev = platform/application area integration branch
|
||||
website-dev = website/marketing area integration branch
|
||||
```
|
||||
|
||||
For platform features:
|
||||
|
||||
```text
|
||||
platform-dev
|
||||
↓
|
||||
feature branch
|
||||
↓
|
||||
PR back to platform-dev
|
||||
↓
|
||||
platform-dev → dev integration PR
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Platform feature branches MUST target `platform-dev`.
|
||||
- Do NOT target `dev` directly unless the user explicitly asks.
|
||||
- Do NOT use `website-dev` for platform features.
|
||||
- `platform-dev` is the default PR base for TenantPilot platform/application work.
|
||||
- `dev` is the shared integration branch.
|
||||
|
||||
### Solo Workflow Rule
|
||||
|
||||
The user works alone on `platform-dev`.
|
||||
|
||||
For refreshing the integration branch before opening or updating the PR `platform-dev` → `dev`, prefer rebase over merge.
|
||||
|
||||
Do not repeatedly merge `origin/dev` into `platform-dev` for refresh.
|
||||
|
||||
Avoid creating repeated merge commits like:
|
||||
|
||||
```text
|
||||
Merge remote-tracking branch 'origin/dev' into platform-dev
|
||||
```
|
||||
|
||||
Use `--force-with-lease`, never plain `--force`.
|
||||
|
||||
If rebase conflicts occur, stop and report the conflict files.
|
||||
|
||||
---
|
||||
|
||||
## Preconditions
|
||||
|
||||
Before committing:
|
||||
|
||||
1. Confirm repository root.
|
||||
2. Confirm current branch is not protected.
|
||||
|
||||
Protected branches:
|
||||
|
||||
```text
|
||||
dev
|
||||
platform-dev
|
||||
website-dev
|
||||
main
|
||||
master
|
||||
```
|
||||
|
||||
If the current branch is protected, STOP and report:
|
||||
|
||||
```text
|
||||
Ich bin auf einem geschützten Branch. Bitte zuerst einen Feature-Branch auschecken.
|
||||
```
|
||||
|
||||
3. Confirm remote exists.
|
||||
4. Confirm there are local changes, untracked files, or unpushed commits.
|
||||
5. Confirm there are no unresolved conflicts.
|
||||
|
||||
Do not ask for confirmation unless:
|
||||
|
||||
- The current branch is protected.
|
||||
- Git status indicates unresolved conflicts.
|
||||
- There is no remote configured.
|
||||
- `.env` or other local secret/config files would be committed.
|
||||
- Commit fails.
|
||||
- Push fails.
|
||||
- Gitea MCP PR creation fails.
|
||||
|
||||
---
|
||||
|
||||
## Required Tools
|
||||
|
||||
Use terminal for Git operations.
|
||||
|
||||
Use Gitea MCP for pull request creation.
|
||||
|
||||
Preferred Gitea MCP operation:
|
||||
|
||||
```text
|
||||
create_pull_request
|
||||
```
|
||||
|
||||
Required PR parameters:
|
||||
|
||||
```json
|
||||
{
|
||||
"owner": "ahmido",
|
||||
"repo": "TenantAtlas",
|
||||
"head": "<current-feature-branch>",
|
||||
"base": "platform-dev",
|
||||
"title": "<generated-title>",
|
||||
"body": "<generated-body>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Inspect Git state
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git rev-parse --show-toplevel
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
git status --porcelain
|
||||
git status -sb
|
||||
git config --get remote.origin.url
|
||||
git log --oneline --max-count=5
|
||||
```
|
||||
|
||||
Determine:
|
||||
|
||||
- repository root
|
||||
- current branch
|
||||
- changed files
|
||||
- untracked files
|
||||
- remote URL
|
||||
- whether there are unpushed commits
|
||||
- whether unresolved conflicts exist
|
||||
|
||||
If the current branch is protected, stop.
|
||||
|
||||
If unresolved conflicts exist, stop.
|
||||
|
||||
If no remote exists, stop.
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — Check for local environment files
|
||||
|
||||
Before `git add -A`, check whether local environment/config files are modified or untracked:
|
||||
|
||||
```bash
|
||||
git status --porcelain | grep -E '(^.. \.env$|^.. apps/platform/\.env$|^.. .*\.env$)' || true
|
||||
```
|
||||
|
||||
If `.env` or another environment file is included, STOP and report:
|
||||
|
||||
```text
|
||||
Achtung: Eine .env-/Environment-Datei ist geändert oder untracked. Ich committe das nicht automatisch. Bitte prüfen oder aus dem Commit entfernen.
|
||||
```
|
||||
|
||||
Do not commit secrets or local runtime configuration.
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — Build commit message
|
||||
|
||||
Use the current branch name.
|
||||
|
||||
If branch starts with a spec number, for example:
|
||||
|
||||
```text
|
||||
256-external-support-desk-handoff
|
||||
```
|
||||
|
||||
Generate:
|
||||
|
||||
```text
|
||||
feat(specs/256): external support desk handoff
|
||||
```
|
||||
|
||||
If branch does not contain a spec number, generate:
|
||||
|
||||
```text
|
||||
feat(platform): complete <branch-name>
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Use lowercase subject.
|
||||
- Use feature-style subject.
|
||||
- Do not include `WIP`.
|
||||
- Do not include `final`.
|
||||
- Do not include overly generic `updates`.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
feat(specs/256): external support desk handoff
|
||||
feat(specs/252): platform localization v1
|
||||
feat(platform): improve tenant review workspace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 4 — Commit all changes
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "<commit-message>"
|
||||
```
|
||||
|
||||
If there are no local changes to commit, continue only if the branch has unpushed commits.
|
||||
|
||||
Check unpushed commits with:
|
||||
|
||||
```bash
|
||||
git status -sb
|
||||
git log --oneline origin/<current-branch>..HEAD
|
||||
```
|
||||
|
||||
If there are no local changes and no unpushed commits, report:
|
||||
|
||||
```text
|
||||
Es gibt keine lokalen Änderungen und keine unpushed commits. Ich erstelle keinen leeren Commit.
|
||||
```
|
||||
|
||||
Then continue to PR creation only if the branch already exists remotely or can be pushed.
|
||||
|
||||
---
|
||||
|
||||
### Step 5 — Push branch
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git push --set-upstream origin <current-branch>
|
||||
```
|
||||
|
||||
If the upstream already exists, this is acceptable.
|
||||
|
||||
Never force-push unless the user explicitly requests it.
|
||||
|
||||
---
|
||||
|
||||
### Step 6 — Create PR into platform-dev via Gitea MCP
|
||||
|
||||
Use Gitea MCP to create a pull request:
|
||||
|
||||
```json
|
||||
{
|
||||
"owner": "ahmido",
|
||||
"repo": "TenantAtlas",
|
||||
"head": "<current-feature-branch>",
|
||||
"base": "platform-dev",
|
||||
"title": "<commit-message>",
|
||||
"body": "Implements platform feature branch `<current-feature-branch>`.\n\nTarget branch: `platform-dev`.\n\nFollow-up integration path after merge:\n\n`platform-dev` → `dev`."
|
||||
}
|
||||
```
|
||||
|
||||
If a PR already exists for the same branch and base, do not create a duplicate.
|
||||
|
||||
Report the existing PR if available.
|
||||
|
||||
---
|
||||
|
||||
## Optional Step — Check platform-dev to dev PR
|
||||
|
||||
After creating the feature PR, check whether an open integration PR exists:
|
||||
|
||||
```text
|
||||
platform-dev → dev
|
||||
```
|
||||
|
||||
If a Gitea MCP list/search pull request function is available, use it.
|
||||
|
||||
If one exists, report:
|
||||
|
||||
```text
|
||||
Der Folge-PR `platform-dev` → `dev` existiert bereits: <url>
|
||||
```
|
||||
|
||||
If none exists, report:
|
||||
|
||||
```text
|
||||
Nach dem Merge dieses Feature-PRs sollte der Integrations-PR `platform-dev` → `dev` erstellt oder aktualisiert werden.
|
||||
```
|
||||
|
||||
Do not automatically create the `platform-dev` → `dev` PR unless the user explicitly asks for it.
|
||||
|
||||
Reason: before the feature PR is merged into `platform-dev`, the integration PR may not include the new feature yet.
|
||||
|
||||
---
|
||||
|
||||
## Integration Refresh Mode
|
||||
|
||||
Use this mode when the user explicitly says one of the following:
|
||||
|
||||
- "platform-dev nach dev vorbereiten"
|
||||
- "platform-dev PR aktualisieren"
|
||||
- "out-of-date mit dev beheben"
|
||||
- "integration PR refresh"
|
||||
- "platform-dev auf dev rebasen"
|
||||
- "auch platform-dev nach dev"
|
||||
- "und danach platform-dev nach dev"
|
||||
- "full integration"
|
||||
- "kompletten platform-dev zu dev PR machen"
|
||||
- "folge-pr erstellen"
|
||||
|
||||
This mode prepares or updates the integration PR:
|
||||
|
||||
```text
|
||||
platform-dev → dev
|
||||
```
|
||||
|
||||
Because the user works alone on `platform-dev`, prefer rebase over merge.
|
||||
|
||||
### Integration Refresh Preconditions
|
||||
|
||||
Before running this mode:
|
||||
|
||||
1. Ensure the working tree is clean.
|
||||
2. Ensure there are no unresolved conflicts.
|
||||
3. Fetch remote branches.
|
||||
4. Ensure `origin/platform-dev` exists.
|
||||
5. Ensure `origin/dev` exists.
|
||||
|
||||
If the working tree is dirty, STOP and report:
|
||||
|
||||
```text
|
||||
Der Working Tree ist nicht sauber. Bitte erst Änderungen committen, stashen oder verwerfen, bevor `platform-dev` auf `dev` rebased wird.
|
||||
```
|
||||
|
||||
If unresolved conflicts exist, STOP and report the conflict files.
|
||||
|
||||
### Integration Refresh Workflow
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout platform-dev
|
||||
git reset --hard origin/platform-dev
|
||||
git rebase origin/dev
|
||||
git push --force-with-lease origin platform-dev
|
||||
```
|
||||
|
||||
After pushing, verify that `origin/dev` is now an ancestor of `origin/platform-dev`:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git merge-base --is-ancestor origin/dev origin/platform-dev \
|
||||
&& echo "OK: platform-dev contains dev" \
|
||||
|| echo "OUTDATED: platform-dev does not contain dev"
|
||||
```
|
||||
|
||||
If the verification prints `OUTDATED`, stop and report it. Do not claim the PR is up-to-date.
|
||||
|
||||
Rules:
|
||||
|
||||
- Do not merge `origin/dev` into `platform-dev` for this refresh.
|
||||
- Do not create repeated merge commits from `origin/dev` into `platform-dev`.
|
||||
- Use `git push --force-with-lease origin platform-dev` after a successful rebase.
|
||||
- Never use plain `git push --force`.
|
||||
- If `git rebase origin/dev` reports conflicts, stop immediately.
|
||||
- Do not continue to PR creation while a rebase is unresolved.
|
||||
- Do not auto-merge the PR.
|
||||
- Do not claim Gitea will remove the out-of-date warning unless the ancestor check succeeds.
|
||||
|
||||
If rebase conflicts occur, report:
|
||||
|
||||
```text
|
||||
Rebase-Konflikte erkannt. Ich habe gestoppt.
|
||||
|
||||
Konfliktdateien:
|
||||
<files>
|
||||
|
||||
Bitte Konflikte lösen, dann `git rebase --continue` ausführen oder den Rebase mit `git rebase --abort` abbrechen.
|
||||
```
|
||||
|
||||
### Create or Report Integration PR
|
||||
|
||||
After the rebase, push, and ancestor verification succeeded, use Gitea MCP to create or report the integration PR:
|
||||
|
||||
```json
|
||||
{
|
||||
"owner": "ahmido",
|
||||
"repo": "TenantAtlas",
|
||||
"head": "platform-dev",
|
||||
"base": "dev",
|
||||
"title": "chore(platform): merge platform-dev into dev",
|
||||
"body": "Integrates latest TenantPilot platform changes from `platform-dev` into `dev`.\n\nThis PR was created by agent on user request; do not merge automatically."
|
||||
}
|
||||
```
|
||||
|
||||
If an open PR already exists for `platform-dev` → `dev`, do not create a duplicate. Report the existing PR.
|
||||
|
||||
### Integration Refresh Reporting Format
|
||||
|
||||
Final response for this mode must include:
|
||||
|
||||
```text
|
||||
Fertig.
|
||||
|
||||
- Branch aktualisiert: platform-dev
|
||||
- Refresh-Methode: rebase auf origin/dev
|
||||
- Ancestor-Check: origin/dev ist Ancestor von origin/platform-dev
|
||||
- Push: --force-with-lease origin/platform-dev
|
||||
- Integration PR: <url>
|
||||
- Base: dev
|
||||
- Hinweis: PR wurde nicht automatisch gemerged.
|
||||
```
|
||||
|
||||
Do not claim tests passed unless they were actually executed.
|
||||
|
||||
---
|
||||
|
||||
## Reporting Format
|
||||
|
||||
Final response must be concise and include:
|
||||
|
||||
```text
|
||||
Fertig.
|
||||
|
||||
- Branch: <branch>
|
||||
- Commit: <commit-sha or "keine neuen Änderungen">
|
||||
- Push: origin/<branch>
|
||||
- PR: <url>
|
||||
- Base: platform-dev
|
||||
- Nächster Schritt: Nach Merge `platform-dev` → `dev` PR aktualisieren/erstellen
|
||||
```
|
||||
|
||||
If tests were not run, say:
|
||||
|
||||
```text
|
||||
Tests wurden in diesem Skill nicht automatisch ausgeführt.
|
||||
```
|
||||
|
||||
Do not claim tests passed unless the tool actually ran them.
|
||||
|
||||
---
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Never commit directly to `dev`, `platform-dev`, `website-dev`, `main`, or `master`.
|
||||
- Never force-push unless explicitly requested.
|
||||
- For Integration Refresh Mode only, `git push --force-with-lease origin platform-dev` is allowed because the user works alone on `platform-dev`; never use plain `--force`.
|
||||
- Never auto-merge PRs unless explicitly requested.
|
||||
- Never target `dev` directly for platform feature PRs unless explicitly requested.
|
||||
- Never delete branches unless explicitly requested.
|
||||
- Never claim tests were run unless the tool actually ran them.
|
||||
- Never commit `.env`, secrets, local tokens, local mock-server configuration, or temporary runtime-only changes.
|
||||
- If migrations were created, mention that the target environment needs migration execution after deployment.
|
||||
- If unresolved conflicts exist, stop.
|
||||
|
||||
---
|
||||
|
||||
## Useful Commands
|
||||
|
||||
Inspect:
|
||||
|
||||
```bash
|
||||
git rev-parse --show-toplevel
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
git status --porcelain
|
||||
git status -sb
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
Detect protected branch:
|
||||
|
||||
```bash
|
||||
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||
case "$branch" in
|
||||
dev|platform-dev|website-dev|main|master)
|
||||
echo "PROTECTED_BRANCH:$branch"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
```
|
||||
|
||||
Detect unresolved conflicts:
|
||||
|
||||
```bash
|
||||
git diff --name-only --diff-filter=U
|
||||
```
|
||||
|
||||
Detect `.env` changes:
|
||||
|
||||
```bash
|
||||
git status --porcelain | grep -E '(^.. \.env$|^.. apps/platform/\.env$|^.. .*\.env$)' || true
|
||||
```
|
||||
|
||||
Commit:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "<message>"
|
||||
```
|
||||
|
||||
Push:
|
||||
|
||||
```bash
|
||||
git push --set-upstream origin "$(git rev-parse --abbrev-ref HEAD)"
|
||||
```
|
||||
|
||||
Latest commit:
|
||||
|
||||
```bash
|
||||
git rev-parse --short HEAD
|
||||
git log -1 --pretty=%s
|
||||
```
|
||||
|
||||
Integration refresh:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout platform-dev
|
||||
git reset --hard origin/platform-dev
|
||||
git rebase origin/dev
|
||||
git push --force-with-lease origin platform-dev
|
||||
```
|
||||
|
||||
Verify integration refresh:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git merge-base --is-ancestor origin/dev origin/platform-dev \
|
||||
&& echo "OK: platform-dev contains dev" \
|
||||
|| echo "OUTDATED: platform-dev does not contain dev"
|
||||
```
|
||||
|
||||
Check rebase conflicts:
|
||||
|
||||
```bash
|
||||
git diff --name-only --diff-filter=U
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example User Request
|
||||
|
||||
User:
|
||||
|
||||
```text
|
||||
alles committen pushen und pr gegen platform-dev mit gitea mcp
|
||||
```
|
||||
|
||||
Assistant should:
|
||||
|
||||
1. Check current branch.
|
||||
2. Stop if branch is protected.
|
||||
3. Stop if `.env` or secrets would be committed.
|
||||
4. Commit all changes.
|
||||
5. Push current branch.
|
||||
6. Create PR into `platform-dev` with Gitea MCP.
|
||||
7. Report result.
|
||||
|
||||
Do not ask unnecessary follow-up questions.
|
||||
|
||||
---
|
||||
|
||||
## Example Integration Refresh Request
|
||||
|
||||
User:
|
||||
|
||||
```text
|
||||
platform-dev PR aktualisieren
|
||||
```
|
||||
|
||||
Assistant should:
|
||||
|
||||
1. Ensure the working tree is clean.
|
||||
2. Fetch origin.
|
||||
3. Checkout `platform-dev`.
|
||||
4. Reset local `platform-dev` to `origin/platform-dev`.
|
||||
5. Rebase `platform-dev` onto `origin/dev`.
|
||||
6. Push with `--force-with-lease`.
|
||||
7. Verify `origin/dev` is an ancestor of `origin/platform-dev`.
|
||||
8. Create or report the PR `platform-dev` → `dev`.
|
||||
9. Report result.
|
||||
|
||||
Do not merge the PR automatically.
|
||||
447
.github/skills/spec-kit-implementation-loop/SKILL.md
vendored
447
.github/skills/spec-kit-implementation-loop/SKILL.md
vendored
@ -1,447 +0,0 @@
|
||||
---
|
||||
name: spec-kit-implementation-loop
|
||||
description: Implement an existing TenantPilot/TenantAtlas Spec Kit feature, run tests, browser smoke checks where applicable, post-implementation analysis, fix all confirmed in-scope findings when safe and bounded, and repeat until no in-scope findings remain or a stop condition is reached.
|
||||
---
|
||||
|
||||
# Skill: Spec Kit Implementation Loop
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this skill to implement an already prepared TenantPilot/TenantAtlas Spec Kit feature and verify it with a bounded implementation loop.
|
||||
|
||||
This skill assumes `spec.md`, `plan.md`, and `tasks.md` already exist and have passed preparation readiness or have been explicitly accepted by the user.
|
||||
|
||||
The intended workflow is:
|
||||
|
||||
```text
|
||||
active or explicitly named spec
|
||||
→ inspect repo truth, constitution, spec, plan, tasks, and relevant code/tests
|
||||
→ evaluate implementation gates
|
||||
→ implement strictly task-by-task
|
||||
→ run relevant tests/checks
|
||||
→ run browser smoke test when UI/user-facing flows are affected
|
||||
→ run strict post-implementation analysis
|
||||
→ fix confirmed in-scope findings
|
||||
→ repeat test + browser smoke + analysis + fix loop until clean or bounded stop condition is reached
|
||||
→ final implementation report
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when the user asks to:
|
||||
|
||||
- implement an active or explicitly named Spec Kit feature
|
||||
- run Spec Kit implement
|
||||
- analyze after implementation
|
||||
- fix implementation findings
|
||||
- repeat implementation verification until no confirmed in-scope findings remain
|
||||
- run tests and browser smoke checks after implementation
|
||||
|
||||
Typical user prompts:
|
||||
|
||||
```text
|
||||
Implementiere die aktive Spec und analysiere danach, ob alles passt.
|
||||
```
|
||||
|
||||
```text
|
||||
Implementiere specs/243-product-usage-adoption-telemetry streng nach tasks.md.
|
||||
```
|
||||
|
||||
```text
|
||||
Mach Spec Kit implement und danach analyse. Behebe alle Abweichungen und wiederhole bis sauber.
|
||||
```
|
||||
|
||||
```text
|
||||
Implementiere die vorbereitete Spec. Danach Tests, Browser Smoke Test falls UI betroffen ist, Analyse und Fix-Loop bis keine In-Scope Findings mehr offen sind.
|
||||
```
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Work strictly repo-based.
|
||||
- Implement only the active or explicitly named Spec Kit feature.
|
||||
- Do not choose a new candidate.
|
||||
- Do not create a new spec.
|
||||
- Do not expand scope beyond `spec.md`, `plan.md`, and `tasks.md`.
|
||||
- Do not silently add roadmap features, adjacent UX rewrites, speculative architecture, or unrelated refactors.
|
||||
- Follow the repository constitution and existing Spec Kit conventions.
|
||||
- Preserve TenantPilot/TenantAtlas terminology.
|
||||
- Prefer small, reviewable patches over broad rewrites.
|
||||
- Treat repository truth as authoritative over assumptions.
|
||||
- If repository truth conflicts with implementation scope, stop and report the conflict unless there is an obvious minimal correction inside active spec scope.
|
||||
- Fix only confirmed findings from tests, static checks, browser smoke checks, or post-implementation analysis.
|
||||
- Fix all confirmed in-scope findings, regardless of severity, when they are safe and bounded.
|
||||
- Do not leave Medium/Low findings open silently. If they are not fixed, document exactly why.
|
||||
- Never hide failing tests, weaken assertions, delete meaningful coverage, or mark tasks complete without implementation evidence.
|
||||
- Do not run destructive commands.
|
||||
- Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||
- Do not perform database-destructive actions unless the repository test workflow explicitly requires isolated test database resets.
|
||||
- Do not continue analysis/fix loops indefinitely.
|
||||
- Do not move from implementation to final status unless the Test Gate, Browser Smoke Test Gate where applicable, and Post-Implementation Analysis Gate have been evaluated.
|
||||
- Do not claim merge-readiness unless the Merge Readiness Gate passes.
|
||||
|
||||
## Required Inputs
|
||||
|
||||
The user should provide at least one of:
|
||||
|
||||
- explicit spec directory such as `specs/<number>-<slug>/`
|
||||
- instruction to use the current active Spec Kit feature
|
||||
- instruction to implement the prepared/current spec
|
||||
|
||||
If the active spec cannot be determined safely, inspect the repository Spec Kit context first. If it is still ambiguous, stop and ask for the specific spec directory.
|
||||
|
||||
## Required Repository Checks
|
||||
|
||||
Always check:
|
||||
|
||||
1. active Spec Kit context / current branch
|
||||
2. git status
|
||||
3. `.specify/memory/constitution.md`
|
||||
4. the active spec directory
|
||||
5. `spec.md`
|
||||
6. `plan.md`
|
||||
7. `tasks.md`
|
||||
8. relevant templates or conventions under `.specify/templates/`
|
||||
9. nearby existing specs with related terminology or scope
|
||||
10. application code surfaces referenced by the active spec
|
||||
11. existing tests related to the changed behavior
|
||||
|
||||
## Git and Branch Safety
|
||||
|
||||
Before making implementation changes:
|
||||
|
||||
1. Check the current branch.
|
||||
2. Check whether the working tree is clean.
|
||||
3. If there are unrelated uncommitted changes, stop and report them. Do not continue.
|
||||
4. If the working tree only contains user-intended changes for this operation, continue cautiously.
|
||||
5. Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||
6. Do not overwrite unrelated work.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
### Gate 1: Spec Readiness Gate
|
||||
|
||||
Required before implementation starts.
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- `spec.md`, `plan.md`, and `tasks.md` exist.
|
||||
- The spec has clear problem statement, user value, functional requirements, out-of-scope boundaries, acceptance criteria, assumptions, and risks.
|
||||
- The plan identifies likely affected repo surfaces and does not contradict repository architecture.
|
||||
- The tasks are small, ordered, verifiable, and include test/validation tasks.
|
||||
- RBAC, workspace/tenant isolation, auditability, OperationRun semantics, evidence/result-truth, and UX requirements are addressed where relevant.
|
||||
- No open question blocks safe implementation.
|
||||
- The scope is small enough for a bounded implementation loop.
|
||||
|
||||
Fail behavior:
|
||||
|
||||
- Stop before implementation.
|
||||
- Report readiness gaps.
|
||||
- Do not compensate for an unclear spec by inventing implementation scope.
|
||||
|
||||
### Gate 2: Implementation Scope Gate
|
||||
|
||||
Required before changing application code.
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- The active spec directory is known.
|
||||
- The implementation target is traceable to specific tasks in `tasks.md`.
|
||||
- The affected files/surfaces are consistent with `plan.md` or clearly justified by repository truth.
|
||||
- No required change would introduce unrelated product behavior.
|
||||
- No required change conflicts with constitution, existing architecture, RBAC/isolation boundaries, or source-of-truth semantics.
|
||||
|
||||
Fail behavior:
|
||||
|
||||
- Stop before code changes and report the conflict or ambiguity.
|
||||
- Suggest a minimal spec/plan/tasks correction if the issue is in the artifacts rather than the codebase.
|
||||
|
||||
### Gate 3: Test Gate
|
||||
|
||||
Required after implementation and after each fix iteration.
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- Targeted tests for changed behavior pass.
|
||||
- Relevant existing tests pass or failures are proven unrelated and documented.
|
||||
- Static analysis, linting, formatting, or type checks used by the repository pass when applicable.
|
||||
- Security/governance-relevant changes have backend, policy, or domain coverage; UI-only verification is not enough.
|
||||
- Regression coverage exists for each fixed Blocker or High finding where practical.
|
||||
|
||||
Fail behavior:
|
||||
|
||||
- Fix in-scope failures before post-implementation analysis.
|
||||
- If failures are unrelated or pre-existing, document evidence and continue only if they do not invalidate the active spec.
|
||||
- Do not weaken tests to pass the gate.
|
||||
|
||||
### Gate 4: Browser Smoke Test Gate
|
||||
|
||||
Required before claiming implementation is ready for manual review/merge when the change affects Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow.
|
||||
|
||||
Not required for backend-only, domain-only, enum-only, contract-only, or test-only changes unless those changes alter a user-facing flow.
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- The relevant page or flow loads in a real browser or the repository's browser-testing harness.
|
||||
- The primary action introduced or changed by the spec can be executed successfully.
|
||||
- Expected UI states, labels, badges, actions, empty states, tables, forms, modals, and navigation are visible where relevant.
|
||||
- Workspace/tenant context is preserved across the tested flow where relevant.
|
||||
- RBAC/capability-dependent visibility behaves as expected where practical to verify.
|
||||
- Livewire interactions complete without visible runtime errors.
|
||||
- No relevant browser console errors occur.
|
||||
- No failed network requests occur for the tested flow, except known unrelated development noise that is explicitly documented.
|
||||
- OperationRun, audit, evidence, result, or support-diagnostic drilldowns work where relevant.
|
||||
- The smoke-tested path is documented in the final response.
|
||||
|
||||
Fail behavior:
|
||||
|
||||
- Fix in-scope browser, UX, Livewire, navigation, or runtime failures before claiming merge-readiness.
|
||||
- If a browser issue is unrelated existing debt, document evidence and residual risk.
|
||||
- Do not treat a passing browser smoke test as a substitute for backend, policy, domain, security, feature, or integration tests.
|
||||
- Do not expand the smoke test into a full E2E suite unless the user explicitly asks for that.
|
||||
|
||||
### Gate 5: Post-Implementation Analysis Gate
|
||||
|
||||
Required after implementation and after each fix iteration.
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- The implementation has been checked against `spec.md`, `plan.md`, `tasks.md`, and constitution.
|
||||
- All completed tasks have implementation evidence.
|
||||
- No confirmed in-scope findings remain.
|
||||
- Medium/Low findings are fixed when they are inside active spec scope, clearly bounded, and safe.
|
||||
- Medium/Low findings that remain open are explicitly documented with one of these reasons:
|
||||
- out of scope
|
||||
- requires separate spec
|
||||
- risky refactor
|
||||
- existing unrelated debt
|
||||
- not reproducible
|
||||
- blocked by unclear product/architecture decision
|
||||
- No scope expansion was introduced during fixes.
|
||||
|
||||
Fail behavior:
|
||||
|
||||
- Fix confirmed in-scope findings, regardless of severity, when the fix is safe and bounded.
|
||||
- Stop instead of fixing when remediation would expand scope, contradict repo architecture, introduce risky refactors, or repeat the same failed fix twice.
|
||||
|
||||
### Gate 6: Merge Readiness Gate
|
||||
|
||||
Required before claiming the implementation is ready for manual review/merge.
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- Spec Readiness Gate passed.
|
||||
- Implementation Scope Gate passed.
|
||||
- Test Gate passed.
|
||||
- Browser Smoke Test Gate passed when applicable, or was explicitly marked not applicable with a reason.
|
||||
- Post-Implementation Analysis Gate passed.
|
||||
- `tasks.md` reflects actual completion status.
|
||||
- No confirmed in-scope findings remain.
|
||||
- All remaining findings are documented as out-of-scope, follow-up candidates, unrelated existing debt, or explicit residual risks.
|
||||
- Final response includes changed files, tests/checks run, browser smoke result, iterations performed, residual risks, and follow-up candidates.
|
||||
|
||||
Fail behavior:
|
||||
|
||||
- Do not claim merge-readiness.
|
||||
- Report the failed gate, remaining risks, and the smallest recommended next action.
|
||||
|
||||
## Implementation Loop
|
||||
|
||||
Execute the loop in bounded phases:
|
||||
|
||||
1. Evaluate the Spec Readiness Gate.
|
||||
2. Evaluate the Implementation Scope Gate before changing application code.
|
||||
3. Implement the active Spec Kit feature scope task-by-task.
|
||||
4. Run targeted tests and relevant static/dynamic checks.
|
||||
5. Evaluate the Test Gate.
|
||||
6. Run a Browser Smoke Test when the change affects UI/user-facing flows.
|
||||
7. Evaluate the Browser Smoke Test Gate as passed, failed, or not applicable with a reason.
|
||||
8. Run strict post-implementation analysis against spec, plan, tasks, constitution, changed code, changed tests, browser smoke results where applicable, and relevant existing patterns.
|
||||
9. Evaluate the Post-Implementation Analysis Gate.
|
||||
10. Identify confirmed findings by severity: Blocker, High, Medium, Low.
|
||||
11. Fix all confirmed in-scope findings regardless of severity when safe and bounded.
|
||||
12. Do not fix findings that require scope expansion, risky unrelated refactors, or architectural/product decisions outside the active spec; document them as follow-up/residual risks with reasons.
|
||||
13. Re-run relevant tests and browser smoke checks where applicable after fixes.
|
||||
14. Repeat test + browser smoke + analysis + fix loop until no confirmed in-scope findings remain or a stop condition is reached.
|
||||
15. Evaluate the Merge Readiness Gate.
|
||||
16. Report final implementation status, changed files, tests, browser smoke result, residual risks, failed/passed gates, and manual review prompt.
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
Stop the implementation loop when any of the following is true:
|
||||
|
||||
- No confirmed in-scope findings remain.
|
||||
- The same finding appears twice after attempted fixes.
|
||||
- A required fix conflicts with the spec, plan, constitution, or repository architecture.
|
||||
- A required fix would expand scope beyond the active spec.
|
||||
- A required fix would require a risky unrelated refactor.
|
||||
- A required fix depends on an unresolved product or architecture decision.
|
||||
- Tests reveal an unrelated pre-existing failure that cannot be safely fixed inside the active spec.
|
||||
- Browser smoke testing reveals an unrelated pre-existing UI/runtime failure that cannot be safely fixed inside the active spec.
|
||||
- Three analysis/fix iterations have already been completed.
|
||||
- The repository state is ambiguous enough that continuing would risk damaging architecture or data semantics.
|
||||
|
||||
When stopping before full cleanliness, report exactly why the loop stopped and what remains.
|
||||
|
||||
## Post-Implementation Analysis Prompt
|
||||
|
||||
Use this prompt internally after implementation and after each fix iteration:
|
||||
|
||||
```markdown
|
||||
Du bist ein Senior Staff Software Engineer, Software Architect und Enterprise SaaS Reviewer.
|
||||
|
||||
Analysiere die Implementierung der aktiven Spec streng repo-basiert.
|
||||
|
||||
Ziel:
|
||||
Prüfe, ob die Umsetzung vollständig, konsistent, getestet und constitution-konform ist.
|
||||
|
||||
Prüfe gegen:
|
||||
- spec.md
|
||||
- plan.md
|
||||
- tasks.md
|
||||
- .specify/memory/constitution.md
|
||||
- geänderte Anwendungscodes
|
||||
- geänderte Tests
|
||||
- Browser-Smoke-Test-Ergebnis, falls UI/user-facing Flows betroffen sind
|
||||
- bestehende Repository-Patterns
|
||||
|
||||
Wichtig:
|
||||
- Keine Spekulation ohne Repo-Beleg.
|
||||
- Keine Scope-Erweiterung.
|
||||
- Keine neuen Produktideen als Pflicht-Fixes.
|
||||
- Findings nach Blocker, High, Medium, Low gruppieren.
|
||||
- Für jedes Finding konkrete Datei-/Code-Belege nennen.
|
||||
- Für jedes Finding eine minimale Remediation nennen.
|
||||
- Separat ausweisen, welche Findings innerhalb der aktiven Spec behoben werden müssen.
|
||||
- Medium/Low Findings innerhalb der aktiven Spec ebenfalls zur Behebung markieren, wenn sie sicher und bounded sind.
|
||||
- Bei UI-/Filament-/Livewire-Änderungen prüfen, ob ein Browser Smoke Test durchgeführt wurde und ob der getestete Operator-Flow wirklich funktioniert.
|
||||
- Findings, die nicht behoben werden sollen, nur als Follow-up/Residual Risk ausweisen, wenn sie out of scope, risky refactor, unrelated existing debt, not reproducible oder durch eine offene Produkt-/Architekturentscheidung blockiert sind.
|
||||
- Wenn keine bestätigten In-Scope Findings verbleiben, klare Implementierungsfreigabe geben.
|
||||
```
|
||||
|
||||
## Task Completion Rules
|
||||
|
||||
- Keep `tasks.md` aligned with actual implementation status.
|
||||
- Check off tasks only after the implementation and test evidence exists.
|
||||
- If a task is obsolete because repository truth proves a different path, update the task note with the reason instead of silently deleting it.
|
||||
- If a task cannot be completed inside scope, leave it unchecked and report why.
|
||||
|
||||
## Testing Rules
|
||||
|
||||
- Add or update tests for all changed business behavior.
|
||||
- Include RBAC and workspace/tenant isolation tests where relevant.
|
||||
- Include OperationRun, audit, evidence, or result-truth tests where relevant.
|
||||
- Prefer regression tests for every fixed Blocker or High finding.
|
||||
- Add regression tests for Medium/Low findings when the behavior is important and testable without excessive churn.
|
||||
- Do not weaken tests to pass the suite.
|
||||
- Do not treat a green UI path as sufficient without backend or policy coverage when the behavior is security- or governance-relevant.
|
||||
|
||||
## Browser Smoke Test Rules
|
||||
|
||||
Apply these rules when the active spec changes Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow.
|
||||
|
||||
The browser smoke test should be narrow and focused. It is not a full E2E suite unless explicitly requested.
|
||||
|
||||
Minimum smoke path:
|
||||
|
||||
1. Open the relevant page or entry point.
|
||||
2. Confirm the expected workspace/tenant context where relevant.
|
||||
3. Confirm the changed or newly introduced UI element is visible.
|
||||
4. Execute the primary action or interaction changed by the spec.
|
||||
5. Confirm the expected result state, notification, redirect, table update, modal state, operation link, or drilldown.
|
||||
6. Check for relevant console errors.
|
||||
7. Check for failed network requests related to the tested flow.
|
||||
8. Document the tested path in the final response.
|
||||
|
||||
For TenantPilot/TenantAtlas, pay special attention to:
|
||||
|
||||
- Filament actions and header actions
|
||||
- Livewire polling, modals, validation, and actions
|
||||
- workspace/tenant context preservation
|
||||
- RBAC/capability-dependent action visibility
|
||||
- OperationRun links and drilldown continuity
|
||||
- audit/evidence/result/support-diagnostic drilldowns where relevant
|
||||
- empty states, badges, labels, and decision guidance where relevant
|
||||
|
||||
Browser smoke testing is required for UI/user-facing changes and optional for backend-only changes.
|
||||
|
||||
Do not treat browser smoke success as proof that backend security, policies, domain logic, auditability, or workspace/tenant isolation are correct. Those still require automated tests or repo-based verification.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
If an implementation step, test phase, browser smoke phase, or post-implementation analysis fails:
|
||||
|
||||
1. Stop at the relevant gate or stop condition.
|
||||
2. Report the failing command or phase.
|
||||
3. Summarize the error.
|
||||
4. Do not attempt unrelated implementation as a workaround.
|
||||
5. Suggest the smallest safe next action.
|
||||
|
||||
If the branch or working tree state is unsafe:
|
||||
|
||||
1. Stop before implementation changes.
|
||||
2. Report the current branch and relevant uncommitted files.
|
||||
3. Ask the user to commit, stash, or move to a clean worktree.
|
||||
|
||||
## Final Response Requirements
|
||||
|
||||
Respond with:
|
||||
|
||||
1. Active spec directory
|
||||
2. Summary of implemented changes
|
||||
3. Tests/checks run and their results
|
||||
4. Browser smoke test result, tested path, or not-applicable reason
|
||||
5. Quality gates passed/failed and number of analysis/fix iterations performed
|
||||
6. Remaining in-scope findings, if any
|
||||
7. Residual risks and follow-up candidates, if relevant
|
||||
8. Files changed
|
||||
9. Explicit statement whether the Merge Readiness Gate passed and whether the implementation is ready for manual review/merge
|
||||
|
||||
Keep the final response concise, but include enough detail for the user to continue immediately.
|
||||
|
||||
## Manual Review Prompt
|
||||
|
||||
Provide a ready-to-copy prompt like this, adapted to the active spec number and slug:
|
||||
|
||||
```markdown
|
||||
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
|
||||
|
||||
Führe eine finale manuelle Review der implementierten Spec `<spec-number>-<slug>` streng repo-basiert durch.
|
||||
|
||||
Ziel:
|
||||
Prüfe, ob die Implementierung nach dem Agenten-Loop wirklich merge-ready ist.
|
||||
|
||||
Wichtig:
|
||||
- Keine Implementierung.
|
||||
- Keine Codeänderungen.
|
||||
- Keine Scope-Erweiterung.
|
||||
- Prüfe gegen spec.md, plan.md, tasks.md und constitution.md.
|
||||
- Prüfe die geänderten Dateien, Tests, Browser-Smoke-Test-Ergebnis, RBAC, Workspace-/Tenant-Isolation, Auditability, UX und OperationRun-Semantik, soweit relevant.
|
||||
- Benenne nur konkrete Findings mit Repo-Beleg.
|
||||
- Gib am Ende eine klare Entscheidung: Merge-ready, merge-ready with notes, oder not merge-ready.
|
||||
```
|
||||
|
||||
## Example Invocation
|
||||
|
||||
User:
|
||||
|
||||
```text
|
||||
Nutze den Skill spec-kit-implementation-loop.
|
||||
Implementiere die aktive Spec.
|
||||
Danach Tests ausführen, Browser Smoke Test falls UI/user-facing betroffen ist, Post-Implementation Analyse durchführen und alle bestätigten In-Scope Findings unabhängig von Severity beheben, wenn safe und bounded.
|
||||
Wiederhole test + browser smoke + analysis + fix bis keine In-Scope Findings mehr offen sind oder eine Stop Condition greift.
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
|
||||
1. Inspect active Spec Kit context, constitution, spec, plan, tasks, relevant code, and relevant tests.
|
||||
2. Evaluate the Spec Readiness Gate and Implementation Scope Gate.
|
||||
3. Implement only the active spec scope.
|
||||
4. Run targeted tests and relevant checks.
|
||||
5. Evaluate the Test Gate.
|
||||
6. Run and evaluate Browser Smoke Test when UI/user-facing flows are affected.
|
||||
7. Run post-implementation analysis.
|
||||
8. Fix all confirmed in-scope findings regardless of severity when safe and bounded.
|
||||
9. Repeat test + browser smoke + analysis + fix loop up to the stop conditions.
|
||||
10. Evaluate the Merge Readiness Gate.
|
||||
11. Report final status, changed files, tests, browser smoke result, residual risks, gates, and manual review prompt.
|
||||
```
|
||||
562
.github/skills/spec-kit-next-best-prep/SKILL.md
vendored
562
.github/skills/spec-kit-next-best-prep/SKILL.md
vendored
@ -1,562 +0,0 @@
|
||||
---
|
||||
name: spec-kit-next-best-prep
|
||||
description: Select the next suitable TenantPilot/TenantAtlas spec candidate from roadmap/spec-candidates, run the repository's Spec Kit preparation flow, create or update spec.md/plan.md/tasks.md, run preparation analysis, fix preparation-artifact issues only, and stop before application implementation.
|
||||
---
|
||||
|
||||
# Skill: Spec Kit Next-Best Preparation
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this skill to prepare the next implementation-ready Spec Kit package for TenantPilot/TenantAtlas without implementing application code.
|
||||
|
||||
This skill supports preparation only:
|
||||
|
||||
1. Select or scope the next suitable feature from roadmap/spec-candidates.
|
||||
2. Run the repository's real Spec Kit preparation workflow where available.
|
||||
3. Create or update `spec.md`, `plan.md`, and `tasks.md`.
|
||||
4. Run preparation `analyze` when supported.
|
||||
5. Fix preparation-artifact issues only.
|
||||
6. Evaluate preparation quality gates.
|
||||
7. Stop before application implementation.
|
||||
|
||||
The intended workflow is:
|
||||
|
||||
```text
|
||||
roadmap / spec-candidates / feature idea
|
||||
→ inspect repo truth, constitution, roadmap, spec candidates, existing specs, and relevant code
|
||||
→ select the next suitable candidate or scope the provided idea
|
||||
→ run Spec Kit specify/plan/tasks/analyze where available
|
||||
→ create or update spec.md + plan.md + tasks.md
|
||||
→ fix preparation-artifact issues only
|
||||
→ evaluate Candidate Selection Gate and Spec Readiness Gate
|
||||
→ final preparation report
|
||||
→ explicit implementation step later
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when the user asks to:
|
||||
|
||||
- select the next best spec candidate from `docs/product/spec-candidates.md` and roadmap sources
|
||||
- turn a feature idea, roadmap item, or candidate into `spec.md`, `plan.md`, and `tasks.md`
|
||||
- prepare Spec Kit artifacts in one pass
|
||||
- run specify/plan/tasks/analyze without implementation
|
||||
- fix preparation analysis issues in Spec Kit artifacts only
|
||||
- prepare a feature package for a later implementation skill
|
||||
|
||||
Typical user prompts:
|
||||
|
||||
```text
|
||||
Nimm den nächsten sinnvollen Spec Candidate aus Roadmap/spec-candidates und mach spec, plan und tasks.
|
||||
```
|
||||
|
||||
```text
|
||||
Mach daraus spec, plan und tasks in einem Rutsch, aber noch nicht implementieren.
|
||||
```
|
||||
|
||||
```text
|
||||
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec und führe specify, plan, tasks und analyze aus.
|
||||
```
|
||||
|
||||
```text
|
||||
Behebe alle analyze-Issues in den Spec-Kit-Artefakten. Keine Application-Implementierung.
|
||||
```
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Work strictly repo-based.
|
||||
- This is a preparation-only skill.
|
||||
- Do not implement application code.
|
||||
- Do not modify production code.
|
||||
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, routes, views, tests, or runtime behavior.
|
||||
- Use the repository's actual Spec Kit workflow, scripts, templates, branch naming rules, and generated paths when available.
|
||||
- Do not manually invent spec numbers, branch names, or spec paths if Spec Kit provides a script or command for that.
|
||||
- Do not bypass Spec Kit branch mechanics.
|
||||
- Create or update only Spec Kit preparation artifacts unless repository conventions require additional documentation artifacts.
|
||||
- Do not expand scope beyond the selected feature, `spec.md`, `plan.md`, and `tasks.md`.
|
||||
- Do not silently add roadmap features, adjacent UX rewrites, speculative architecture, or unrelated refactors.
|
||||
- Follow the repository constitution and existing Spec Kit conventions.
|
||||
- Preserve TenantPilot/TenantAtlas terminology.
|
||||
- Prefer small, reviewable, implementation-ready specs over broad rewrites.
|
||||
- Treat repository truth as authoritative over assumptions.
|
||||
- If repository truth conflicts with the user-provided draft or candidate wording, keep repository truth and document the deviation.
|
||||
- Fix only confirmed preparation-artifact findings from Spec Kit preparation analysis.
|
||||
- Do not leave preparation findings open silently. If they are not fixed, document exactly why.
|
||||
- Do not run destructive commands.
|
||||
- Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||
- Do not overwrite existing specs.
|
||||
- Do not move from preparation to an implementation step inside this skill.
|
||||
|
||||
## Required Inputs
|
||||
|
||||
The user should provide at least one of:
|
||||
|
||||
- feature title and short goal
|
||||
- full spec candidate
|
||||
- roadmap item
|
||||
- rough problem statement
|
||||
- UX or architecture improvement idea
|
||||
- instruction to choose the next best candidate from roadmap/spec-candidates
|
||||
|
||||
If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions.
|
||||
|
||||
If no suitable candidate can be selected safely, stop and report why.
|
||||
|
||||
## Required Repository Checks
|
||||
|
||||
Always check:
|
||||
|
||||
1. `.specify/memory/constitution.md`
|
||||
2. `.specify/templates/`
|
||||
3. `.specify/scripts/`
|
||||
4. existing Spec Kit command usage or repository instructions, if present
|
||||
5. current branch and git status
|
||||
6. `specs/`
|
||||
7. `docs/product/spec-candidates.md`
|
||||
8. relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present
|
||||
9. nearby existing specs with related terminology or scope
|
||||
10. application code only as needed to avoid wrong naming, wrong architecture, duplicate concepts, impossible tasks, duplicated specs, or already-completed candidates
|
||||
|
||||
Do not edit application code.
|
||||
|
||||
## Git and Branch Safety
|
||||
|
||||
Before running any Spec Kit command:
|
||||
|
||||
1. Check the current branch.
|
||||
2. Check whether the working tree is clean.
|
||||
3. If there are unrelated uncommitted changes, stop and report them. Do not continue.
|
||||
4. If the working tree only contains user-intended planning edits for this operation, continue cautiously.
|
||||
5. Let Spec Kit create or switch to the correct feature branch when that is how the repository workflow works.
|
||||
6. Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||
7. Do not overwrite existing specs.
|
||||
|
||||
If the repo requires an explicit branch creation script for `specify`, use that script rather than manually creating the branch.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
### Gate 1: Candidate Selection Gate
|
||||
|
||||
Required before creating a new spec from roadmap/spec-candidates.
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- The selected candidate exists in roadmap/spec-candidate material or is directly provided by the user.
|
||||
- The selected candidate is not already covered by an existing active or completed spec.
|
||||
- The selected candidate aligns with current roadmap priorities or explicitly documented product direction.
|
||||
- The candidate can be scoped as a small, reviewable, implementation-ready slice.
|
||||
- Major adjacent concerns are listed as follow-up candidates instead of being hidden inside the primary scope.
|
||||
|
||||
Fail behavior:
|
||||
|
||||
- If no candidate satisfies the gate, stop and report the top candidates plus the reason none is ready.
|
||||
- Do not invent a new roadmap direction to force progress.
|
||||
|
||||
### Gate 2: Spec Readiness Gate
|
||||
|
||||
Required before reporting that the package is ready for implementation.
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- `spec.md`, `plan.md`, and `tasks.md` exist.
|
||||
- The spec has clear problem statement, user value, functional requirements, out-of-scope boundaries, acceptance criteria, assumptions, and risks.
|
||||
- The plan identifies likely affected repo surfaces and does not contradict repository architecture.
|
||||
- The tasks are small, ordered, verifiable, and include test/validation tasks.
|
||||
- RBAC, workspace/tenant isolation, auditability, OperationRun semantics, evidence/result-truth, and UX requirements are addressed where relevant.
|
||||
- No open question blocks safe implementation.
|
||||
- The scope is small enough for a bounded implementation loop in a later implementation skill.
|
||||
- Required checklist artifacts exist when the constitution requires them.
|
||||
|
||||
Fail behavior:
|
||||
|
||||
- Fix preparation-artifact issues when they are safe and bounded.
|
||||
- If readiness cannot be achieved without implementation or unresolved product decisions, stop and report the gap.
|
||||
- Do not compensate for an unclear spec by inventing implementation scope.
|
||||
|
||||
## Candidate Selection Rules
|
||||
|
||||
When the user asks for the next best spec from roadmap/spec-candidates:
|
||||
|
||||
- Read `docs/product/spec-candidates.md`.
|
||||
- Read relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present.
|
||||
- Check existing specs to avoid duplicates.
|
||||
- Prefer candidates that align with current roadmap priorities, platform foundations, enterprise UX, RBAC/isolation, auditability, observability, and governance workflow maturity.
|
||||
- Prefer candidates that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers.
|
||||
- Prefer small, implementation-ready slices over broad platform rewrites.
|
||||
- If multiple candidates are plausible, choose one primary candidate and document why it was selected.
|
||||
- Add non-selected relevant candidates as follow-up spec candidates, not hidden scope.
|
||||
- Do not invent a candidate if existing roadmap/spec-candidate material provides a suitable one.
|
||||
- Do not pick a spec only because it is listed first.
|
||||
- Evaluate the Candidate Selection Gate before creating the spec directory.
|
||||
|
||||
Evaluate candidates using these criteria:
|
||||
|
||||
1. **Roadmap Fit**: Does it support the current roadmap sequence or unlock the next roadmap layer?
|
||||
2. **Foundation Value**: Does it strengthen reusable platform foundations such as RBAC, isolation, auditability, evidence, OperationRun observability, provider boundaries, vocabulary, baseline/control/finding semantics, or enterprise UX patterns?
|
||||
3. **Dependency Unblocking**: Does it make future specs smaller, safer, or more consistent?
|
||||
4. **Scope Size**: Can it be implemented as a narrow, testable slice?
|
||||
5. **Repo Readiness**: Does the repo already have enough structure to implement the next slice safely?
|
||||
6. **Risk Reduction**: Does it reduce current architectural or product risk?
|
||||
7. **User/Product Value**: Does it produce visible operator value or make the platform more sellable without heavy scope?
|
||||
|
||||
## Required Selection Output Before Spec Kit Execution
|
||||
|
||||
Before running the Spec Kit flow, identify:
|
||||
|
||||
- selected candidate title
|
||||
- source location in roadmap/spec-candidates
|
||||
- why it was selected
|
||||
- why close alternatives were deferred
|
||||
- roadmap relationship
|
||||
- smallest viable implementation slice
|
||||
- proposed concise feature description to feed into `specify`
|
||||
|
||||
The feature description must be product- and behavior-oriented. It should not be a low-level implementation plan.
|
||||
|
||||
## Spec Kit Preparation Flow
|
||||
|
||||
### Step 1: Determine the repository's Spec Kit command pattern
|
||||
|
||||
Inspect repository instructions and scripts to identify how this repo expects Spec Kit to be run.
|
||||
|
||||
Common locations to inspect:
|
||||
|
||||
```text
|
||||
.specify/scripts/
|
||||
.specify/templates/
|
||||
.specify/memory/constitution.md
|
||||
.github/prompts/
|
||||
.github/skills/
|
||||
README.md
|
||||
specs/
|
||||
```
|
||||
|
||||
Use the repo-specific mechanism if present.
|
||||
|
||||
### Step 2: Run `specify`
|
||||
|
||||
Run the repository's `specify` flow using the selected candidate and the smallest viable slice.
|
||||
|
||||
The `specify` input should include:
|
||||
|
||||
- selected candidate title
|
||||
- problem statement
|
||||
- operator/user value
|
||||
- roadmap relationship
|
||||
- out-of-scope boundaries
|
||||
- key acceptance criteria
|
||||
- important enterprise constraints
|
||||
|
||||
Let Spec Kit create the correct branch and spec location if that is the repo's configured behavior.
|
||||
|
||||
### Step 3: Run `plan`
|
||||
|
||||
Run the repository's `plan` flow for the generated spec.
|
||||
|
||||
The `plan` input should keep the scope tight and should require repo-based alignment with:
|
||||
|
||||
- constitution
|
||||
- existing architecture
|
||||
- workspace/tenant isolation
|
||||
- RBAC
|
||||
- OperationRun/observability where relevant
|
||||
- evidence/snapshot/truth semantics where relevant
|
||||
- Filament/Livewire conventions where relevant
|
||||
- test strategy
|
||||
|
||||
### Step 4: Run `tasks`
|
||||
|
||||
Run the repository's `tasks` flow for the generated plan.
|
||||
|
||||
The generated tasks must be:
|
||||
|
||||
- ordered
|
||||
- small
|
||||
- testable
|
||||
- grouped by phase
|
||||
- limited to the selected scope
|
||||
- suitable for later implementation or manual analysis before implementation
|
||||
|
||||
### Step 5: Run preparation `analyze`
|
||||
|
||||
Run the repository's `analyze` flow against the generated Spec Kit artifacts when the repository supports it.
|
||||
|
||||
Analyze must check:
|
||||
|
||||
- consistency between `spec.md`, `plan.md`, and `tasks.md`
|
||||
- constitution alignment
|
||||
- roadmap alignment
|
||||
- whether the selected candidate was narrowed safely
|
||||
- whether tasks are complete enough for implementation
|
||||
- whether tasks accidentally require scope not described in the spec
|
||||
- whether plan details conflict with repository architecture or terminology
|
||||
- whether implementation risks are documented instead of silently ignored
|
||||
|
||||
Do not use analyze as a trigger to implement application code.
|
||||
|
||||
### Step 6: Fix preparation-artifact issues only
|
||||
|
||||
If preparation analyze finds issues, fix only Spec Kit preparation artifacts such as:
|
||||
|
||||
- `spec.md`
|
||||
- `plan.md`
|
||||
- `tasks.md`
|
||||
- `checklists/requirements.md` or other generated Spec Kit metadata files, if the repository uses them
|
||||
|
||||
Allowed fixes include:
|
||||
|
||||
- clarify requirements
|
||||
- tighten scope
|
||||
- move out-of-scope work into follow-up candidates
|
||||
- correct terminology
|
||||
- add missing tasks
|
||||
- remove tasks not backed by the spec
|
||||
- align plan language with repository architecture
|
||||
- add missing acceptance criteria or validation tasks
|
||||
- add missing checklist artifacts required by the constitution
|
||||
|
||||
Forbidden fixes include:
|
||||
|
||||
- modifying application code
|
||||
- creating migrations
|
||||
- editing models, services, jobs, policies, Filament resources, Livewire components, tests, commands, routes, or views
|
||||
- running implementation or test-fix loops
|
||||
- changing runtime behavior
|
||||
|
||||
### Step 7: Evaluate the Spec Readiness Gate
|
||||
|
||||
After preparation analyze has passed or preparation-artifact issues have been fixed, evaluate the Spec Readiness Gate.
|
||||
|
||||
Stop after this gate and do not implement.
|
||||
|
||||
## Spec Directory Rules
|
||||
|
||||
When creating a new spec directory, use the repository's Spec Kit-generated directory or path.
|
||||
|
||||
If the repository does not provide a command for spec setup, use the next valid spec number and a kebab-case slug:
|
||||
|
||||
```text
|
||||
specs/<number>-<slug>/
|
||||
```
|
||||
|
||||
The exact number must be derived from the current repository state and existing numbering conventions.
|
||||
|
||||
Create or update preparation artifacts inside the selected spec directory:
|
||||
|
||||
```text
|
||||
specs/<number>-<slug>/spec.md
|
||||
specs/<number>-<slug>/plan.md
|
||||
specs/<number>-<slug>/tasks.md
|
||||
```
|
||||
|
||||
If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions.
|
||||
|
||||
## `spec.md` Requirements
|
||||
|
||||
The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness.
|
||||
|
||||
Include:
|
||||
|
||||
- Feature title
|
||||
- Problem statement
|
||||
- Business/product value
|
||||
- Primary users/operators
|
||||
- User stories
|
||||
- Functional requirements
|
||||
- Non-functional requirements
|
||||
- UX requirements
|
||||
- RBAC/security requirements
|
||||
- Auditability/observability requirements
|
||||
- Data/truth-source requirements where relevant
|
||||
- Out of scope
|
||||
- Acceptance criteria
|
||||
- Success criteria
|
||||
- Risks
|
||||
- Assumptions
|
||||
- Open questions
|
||||
|
||||
TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles:
|
||||
|
||||
- workspace/tenant isolation
|
||||
- capability-first RBAC
|
||||
- auditability
|
||||
- operation/result truth separation
|
||||
- source-of-truth clarity
|
||||
- calm enterprise operator UX
|
||||
- progressive disclosure where useful
|
||||
- no false positive calmness
|
||||
|
||||
## `plan.md` Requirements
|
||||
|
||||
The plan must be repo-aware and implementation-oriented, but it must not make code changes by itself.
|
||||
|
||||
Include:
|
||||
|
||||
- Technical approach
|
||||
- Existing repository surfaces likely affected
|
||||
- Domain/model implications
|
||||
- UI/Filament implications
|
||||
- Livewire implications where relevant
|
||||
- OperationRun/monitoring implications where relevant
|
||||
- RBAC/policy implications
|
||||
- Audit/logging/evidence implications where relevant
|
||||
- Data/migration implications where relevant
|
||||
- Test strategy
|
||||
- Rollout considerations
|
||||
- Risk controls
|
||||
- Implementation phases
|
||||
|
||||
The plan should clearly distinguish where relevant:
|
||||
|
||||
- execution truth
|
||||
- artifact truth
|
||||
- backup/snapshot truth
|
||||
- recovery/evidence truth
|
||||
- operator next action
|
||||
|
||||
## `tasks.md` Requirements
|
||||
|
||||
Tasks must be ordered, small, and verifiable.
|
||||
|
||||
Include:
|
||||
|
||||
- checkbox tasks
|
||||
- phase grouping
|
||||
- tests before or alongside implementation tasks where practical
|
||||
- final validation tasks
|
||||
- documentation/update tasks if needed
|
||||
- explicit non-goals where useful
|
||||
|
||||
Avoid vague tasks such as:
|
||||
|
||||
```text
|
||||
Clean up code
|
||||
Refactor UI
|
||||
Improve performance
|
||||
Make it enterprise-ready
|
||||
```
|
||||
|
||||
Prefer concrete tasks such as:
|
||||
|
||||
```text
|
||||
- [ ] Add a feature test covering workspace isolation for <specific behavior>.
|
||||
- [ ] Update <specific Filament page/resource> to display <specific state>.
|
||||
- [ ] Add policy coverage for <specific capability>.
|
||||
```
|
||||
|
||||
If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths.
|
||||
|
||||
## Preparation Scope Control
|
||||
|
||||
If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section.
|
||||
|
||||
Examples of follow-up candidates:
|
||||
|
||||
- assigned findings
|
||||
- pending approvals
|
||||
- personal work queue
|
||||
- notification delivery settings
|
||||
- evidence pack export hardening
|
||||
- operation monitoring refinements
|
||||
- autonomous governance decision surfaces
|
||||
|
||||
Do not force all follow-up candidates into the primary spec.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
If a Spec Kit command or preparation analyze phase fails:
|
||||
|
||||
1. Stop at the relevant gate.
|
||||
2. Report the failing command or phase.
|
||||
3. Summarize the error.
|
||||
4. Do not attempt implementation as a workaround.
|
||||
5. Suggest the smallest safe next action.
|
||||
|
||||
If the branch or working tree state is unsafe:
|
||||
|
||||
1. Stop before running Spec Kit commands.
|
||||
2. Report the current branch and relevant uncommitted files.
|
||||
3. Ask the user to commit, stash, or move to a clean worktree.
|
||||
|
||||
## Final Response Requirements
|
||||
|
||||
Respond with:
|
||||
|
||||
1. Selected candidate and why it was chosen
|
||||
2. Why close alternatives were deferred
|
||||
3. Current branch after Spec Kit execution, if changed
|
||||
4. Generated spec path
|
||||
5. Files created or updated by Spec Kit
|
||||
6. Preparation analyze result summary
|
||||
7. Preparation-artifact fixes applied after analyze
|
||||
8. Assumptions made
|
||||
9. Open questions, if any
|
||||
10. Candidate Selection Gate result
|
||||
11. Spec Readiness Gate result
|
||||
12. Recommended next implementation prompt
|
||||
13. Explicit statement that no application implementation was performed
|
||||
|
||||
Keep the final response concise, but include enough detail for the user to continue immediately.
|
||||
|
||||
## Manual Review and Next-Step Prompts
|
||||
|
||||
Provide a ready-to-copy manual artifact review prompt like this, adapted to the generated spec branch/path:
|
||||
|
||||
```markdown
|
||||
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
|
||||
|
||||
Analysiere die neu erstellte Spec `<spec-branch-or-spec-path>` streng repo-basiert.
|
||||
|
||||
Ziel:
|
||||
Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind.
|
||||
|
||||
Wichtig:
|
||||
- Keine Implementierung.
|
||||
- Keine Codeänderungen.
|
||||
- Keine Scope-Erweiterung.
|
||||
- Prüfe nur gegen Repo-Wahrheit.
|
||||
- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs.
|
||||
- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor.
|
||||
- Wenn alles passt, gib eine klare Implementierungsfreigabe.
|
||||
```
|
||||
|
||||
Also provide a ready-to-copy implementation prompt for the separate implementation skill after analyze has passed or preparation-artifact issues have been fixed:
|
||||
|
||||
```markdown
|
||||
/spec-kit-implementation-loop
|
||||
|
||||
Implementiere die vorbereitete Spec `<spec-branch-or-spec-path>` streng anhand von `tasks.md`.
|
||||
|
||||
Danach Tests ausführen, Browser Smoke Test falls UI/user-facing betroffen ist, Post-Implementation Analyse durchführen und alle bestätigten In-Scope Findings unabhängig von Severity beheben, wenn safe und bounded.
|
||||
|
||||
Wiederhole test + browser smoke + analysis + fix bis keine In-Scope Findings mehr offen sind oder eine Stop Condition greift.
|
||||
```
|
||||
|
||||
## Example Invocation
|
||||
|
||||
User:
|
||||
|
||||
```text
|
||||
Nutze den Skill spec-kit-next-best-prep.
|
||||
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec.
|
||||
Führe danach GitHub Spec Kit specify, plan, tasks und analyze in einem Rutsch aus.
|
||||
Behebe alle analyze-Issues in den Spec-Kit-Artefakten.
|
||||
Keine Application-Implementierung.
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
|
||||
1. Inspect constitution, Spec Kit scripts/templates, specs, roadmap, and spec candidates.
|
||||
2. Check branch and working tree safety.
|
||||
3. Compare candidate suitability.
|
||||
4. Select the next best candidate.
|
||||
5. Evaluate the Candidate Selection Gate.
|
||||
6. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
|
||||
7. Run the repository's real Spec Kit `plan` flow.
|
||||
8. Run the repository's real Spec Kit `tasks` flow.
|
||||
9. Run the repository's real Spec Kit preparation `analyze` flow.
|
||||
10. Fix analyze issues only in Spec Kit preparation artifacts.
|
||||
11. Evaluate the Spec Readiness Gate.
|
||||
12. Stop before application implementation.
|
||||
13. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, gates, and next implementation prompt.
|
||||
```
|
||||
@ -1,34 +1,28 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 2.10.0 -> 2.11.0
|
||||
- Version change: 2.8.0 -> 2.9.0
|
||||
- Modified principles:
|
||||
- Expanded decision-first and operator-surface rules so operational,
|
||||
governance, evidence, onboarding, review, and support-facing
|
||||
detail/status surfaces separate decision content, operator
|
||||
diagnostics, and support/raw evidence
|
||||
- Expanded review and enforcement expectations so specs, plans,
|
||||
tasks, and checklists must make audience modes, raw/support
|
||||
gating, one dominant next action, and duplicate-truth prevention
|
||||
explicit
|
||||
- Added provider-boundary guardrail set under First Provider Is Not
|
||||
Platform Core (PROV-001 with sub-rules PROV-002 through PROV-005)
|
||||
- Expanded Governance review expectations for provider-owned vs
|
||||
platform-core boundaries
|
||||
- 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
|
||||
- First Provider Is Not Platform Core (PROV-001): keeps Microsoft as
|
||||
the current first provider without allowing provider-specific
|
||||
semantics to silently become platform-core truth; requires explicit
|
||||
review of provider-owned vs platform-core seams and prefers bounded
|
||||
extraction over speculative multi-provider frameworks
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- .specify/templates/spec-template.md: add audience-aware disclosure
|
||||
section + constitution prompts ✅
|
||||
- .specify/templates/plan-template.md: add audience/disclosure
|
||||
planning prompts + constitution checks ✅
|
||||
- .specify/templates/tasks-template.md: add decision/disclosure
|
||||
implementation + test tasks ✅
|
||||
- .specify/templates/checklist-template.md: add disclosure, raw-gating,
|
||||
one-primary-action, and duplicate-truth review checks ✅
|
||||
- docs/product/standards/README.md: refresh constitution index for
|
||||
the new audience-aware disclosure contract ✅
|
||||
- .specify/templates/spec-template.md: add provider-boundary platform
|
||||
core check ✅
|
||||
- .specify/templates/plan-template.md: add provider-boundary planning
|
||||
fields + constitution check ✅
|
||||
- .specify/templates/tasks-template.md: add provider-boundary task
|
||||
requirements ✅
|
||||
- .specify/templates/checklist-template.md: add provider-boundary
|
||||
review checks ✅
|
||||
- Commands checked:
|
||||
- N/A `.specify/templates/commands/*.md` directory is not present
|
||||
- Follow-up TODOs: None
|
||||
@ -313,57 +307,24 @@ ### Operations / Run Observability Standard
|
||||
even if implemented by multiple jobs/steps (“umbrella run”).
|
||||
- “Single-row” runs MUST still use consistent counters (e.g., `total=1`, `processed=0|1`) and outcome derived from success/failure.
|
||||
- Monitoring pages MUST be DB-only at render time (no external calls).
|
||||
- Start surfaces MUST NOT perform remote work inline and MUST NOT compose OperationRun start UX locally; they only:
|
||||
authorize, create/reuse run (dedupe), enqueue work, and hand queued/start-state feedback to the shared
|
||||
OperationRun Start UX Contract.
|
||||
|
||||
### OperationRun Start UX Contract (OPS-UX-START-001)
|
||||
|
||||
- OperationRun UX MUST be contract-driven, not surface-driven.
|
||||
- Any feature that creates, queues, deduplicates, resumes, blocks, completes, or links to an `OperationRun` MUST use
|
||||
the central OperationRun Start UX Contract.
|
||||
- Filament Pages, Resources, Widgets, Livewire Components, Actions, and Services MUST NOT independently compose
|
||||
OperationRun start UX from local pieces.
|
||||
- The shared OperationRun UX layer MUST own:
|
||||
- local start notification / toast
|
||||
- `Open operation` / `View run` link
|
||||
- artifact link such as `View snapshot`, `View pack`, or `View restore`
|
||||
- run-enqueued browser event
|
||||
- queued DB-notification decision
|
||||
- dedupe / already-available / already-running messaging
|
||||
- blocked / failed-to-start messaging
|
||||
- tenant/workspace-safe operation URL resolution
|
||||
- Feature surfaces MAY initiate `OperationRun`s, but they MUST NOT define their own OperationRun UX semantics.
|
||||
- `OperationRun` lifecycle state remains the canonical execution truth.
|
||||
- Queued DB notifications MUST remain explicit opt-in unless the active spec defines a different policy.
|
||||
- Terminal `OperationRun` notifications MUST be emitted through the central OperationRun lifecycle mechanism.
|
||||
- Any exception MUST include:
|
||||
1. an explicit spec decision,
|
||||
2. a documented architecture note,
|
||||
3. a test or guard-test exception with rationale,
|
||||
4. a follow-up migration decision if the exception is temporary.
|
||||
- New OperationRun-starting features MUST include an `OperationRun UX Impact` section in the active spec or plan.
|
||||
- Start surfaces MUST NOT perform remote work inline; they only: authorize, create/reuse run (dedupe), enqueue work,
|
||||
confirm + “View run”.
|
||||
|
||||
### Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
|
||||
|
||||
If a feature creates/reuses `OperationRun`, its default feedback contract is exactly three surfaces.
|
||||
Queued DB notifications are forbidden by default and MAY exist only when the active spec explicitly opts into them
|
||||
through the OperationRun Start UX Contract:
|
||||
If a feature creates/reuses `OperationRun`, it MUST use exactly three feedback surfaces — no others:
|
||||
|
||||
1) Toast (intent only / queued-only)
|
||||
- A toast MAY be shown only when the run is accepted/queued (intent feedback).
|
||||
- The toast MUST use `OperationUxPresenter::queuedToast($operationType)->send()`.
|
||||
- Feature code MUST NOT craft ad-hoc operation toasts.
|
||||
- A dedicated dedupe message MUST use the presenter (e.g., `alreadyQueuedToast(...)`), not `Notification::make()`.
|
||||
- Queued toast copy, action links, artifact links, start-state browser events, and dedupe/start-failure messaging MUST be
|
||||
produced by the shared OperationRun Start UX Contract, not by local surface code.
|
||||
|
||||
2) Progress (active awareness only)
|
||||
- Live progress MUST exist only in:
|
||||
- the global active-ops widget, and
|
||||
- Monitoring → Operation Run Detail.
|
||||
- These surfaces MUST show only active runs (`queued|running`) and MUST never show terminal runs.
|
||||
- Running DB notifications are forbidden.
|
||||
- Determinate progress is allowed ONLY when `summary_counts.total` and `summary_counts.processed` are valid numeric values.
|
||||
- Determinate progress MUST be clamped to 0–100. Otherwise render indeterminate + elapsed time.
|
||||
- The widget MUST NOT show percentage text (optional `processed/total` is allowed).
|
||||
@ -404,10 +365,6 @@ ### Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
|
||||
|
||||
The repo MUST include automated guards (Pest) that fail CI if:
|
||||
- any direct `OperationRun` status/outcome transition occurs outside `OperationRunService`,
|
||||
- feature code bypasses the central OperationRun Start UX Contract for queued/start-state operation UX where the repo's
|
||||
guardable patterns can detect it,
|
||||
- feature code emits queued DB notifications for operations without explicit spec-driven opt-in through the shared
|
||||
OperationRun UX layer,
|
||||
- jobs emit DB notifications for operation completion/abort (`OperationRunCompleted` is the single terminal notification),
|
||||
- deprecated legacy operation notification classes are referenced again.
|
||||
|
||||
@ -593,114 +550,11 @@ ##### Review gate
|
||||
5. Is this a Primary Decision Surface, Secondary Context Surface, or
|
||||
Tertiary Evidence / Diagnostics Surface?
|
||||
6. If it is primary, why can it not live inside an existing decision
|
||||
context?
|
||||
context?
|
||||
7. Does the navigation reflect a workflow or only storage structure?
|
||||
8. Does this reduce search, review, or click work?
|
||||
9. Does this make the product calmer and clearer instead of louder?
|
||||
|
||||
#### Audience-Aware Decision Surfaces & Disclosure Ladder (DECIDE-AUD-001)
|
||||
|
||||
Goal: every operational, governance, evidence, onboarding, review, and
|
||||
support-facing detail or status surface MUST keep customer-readable
|
||||
decision content, operator diagnostics, and support/raw evidence
|
||||
intentionally separated while preserving full depth through progressive
|
||||
disclosure.
|
||||
|
||||
##### Audience ladder is explicit
|
||||
|
||||
- In-scope detail and status surfaces MUST define their content using
|
||||
this three-tier hierarchy when applicable:
|
||||
- decision content
|
||||
- operator diagnostics
|
||||
- support / raw evidence
|
||||
- Surfaces that are reachable by more than one audience class MUST
|
||||
define their default-visible content for at least these layers when
|
||||
applicable:
|
||||
- customer / read-only default
|
||||
- operator / MSP diagnostics
|
||||
- platform / support raw evidence
|
||||
- The surface contract MUST state which capabilities unlock each deeper
|
||||
layer.
|
||||
- Support/raw evidence MUST NOT become the default first-read
|
||||
experience on customer-readable or ordinary operator-facing
|
||||
surfaces.
|
||||
|
||||
##### Customer-readable default path
|
||||
|
||||
- The default reading path for customer/read-only users MUST optimize
|
||||
for status, reason, impact, one dominant next action, and a short
|
||||
result or artifact summary.
|
||||
- Internal lifecycle wording, debug semantics, implementation field
|
||||
names, raw payload fragments, and support-oriented context MUST NOT
|
||||
appear in the default customer-readable path unless they are the only
|
||||
way to understand the first decision.
|
||||
- Default-visible customer/read-only content is responsible for status,
|
||||
reason, impact, the dominant next action, and a concise supporting
|
||||
summary only.
|
||||
|
||||
##### Diagnostics are secondary by default
|
||||
|
||||
- Diagnostics such as lifecycle, timings, verification detail, drift
|
||||
detail, permission detail, provider summaries, or related-operation
|
||||
context MUST be lower-priority than the decision surface and MUST be
|
||||
collapsed, tabbed, grouped, or otherwise progressively disclosed when
|
||||
the first decision does not require them.
|
||||
- Authorized operators MAY expand diagnostics, but diagnostics MUST NOT
|
||||
visually compete with the primary decision block.
|
||||
- Where no support/raw tier is exposed, diagnostics still remain below
|
||||
the decision tier and MUST NOT restate the same decision summary at
|
||||
equal weight.
|
||||
|
||||
##### Raw/support evidence is gated
|
||||
|
||||
- Raw/support evidence such as JSON, raw context payloads,
|
||||
fingerprints, internal reason ownership, platform reason families,
|
||||
monitoring detail, viewer context, or copy/show-raw actions MUST NOT
|
||||
appear in the default decision path.
|
||||
- These details MUST live behind explicit reveal affordances and MUST
|
||||
be capability-gated wherever the audience model distinguishes support
|
||||
or platform users from ordinary operators.
|
||||
- Capability-gated support/raw disclosure MUST fail closed when the
|
||||
actor lacks the required scope or capability.
|
||||
|
||||
##### One dominant next action
|
||||
|
||||
- A decision surface MUST expose exactly one dominant next action in
|
||||
the default-visible region.
|
||||
- Optional secondary actions MAY exist, but they MUST NOT compete with
|
||||
the primary remediation or decision action in prominence.
|
||||
- Contextual navigation such as opening a related run, tenant, report,
|
||||
or technical detail remains secondary.
|
||||
|
||||
##### No duplicate truth across equal-priority cards
|
||||
|
||||
- The same blocker, reason, or next action MUST NOT be repeated across
|
||||
multiple equal-priority cards, sections, or summary blocks on the
|
||||
same default-visible surface.
|
||||
- Supporting evidence MAY restate the underlying proof, but the
|
||||
dominant decision message appears once and diagnostics elaborate
|
||||
beneath it.
|
||||
|
||||
##### Required tests
|
||||
|
||||
- New or materially changed customer/operator-facing detail surfaces
|
||||
MUST include focused tests proving:
|
||||
- default-visible content shows status, reason, impact, and next
|
||||
action,
|
||||
- exactly one dominant next action is primary,
|
||||
- diagnostics are secondary or collapsed,
|
||||
- raw/support evidence is not default-visible,
|
||||
- support/raw sections are capability-gated where applicable,
|
||||
- and duplicate visible decision summaries are absent.
|
||||
|
||||
##### Stored evidence wins over fallback diagnostics
|
||||
|
||||
- When a stored verification or report artifact exists, fallback
|
||||
technical diagnostics SHOULD demote behind supporting evidence or
|
||||
technical details instead of remaining peer-level default content.
|
||||
- Fallback diagnostics MAY become temporarily prominent only when the
|
||||
higher-level artifact does not yet exist or is unavailable.
|
||||
|
||||
#### Surface Taxonomy (UI-SURF-001)
|
||||
|
||||
Every new admin surface MUST be assigned exactly one broad action-surface
|
||||
@ -1424,22 +1278,11 @@ #### Operator Surface Principles (OPSURF-001)
|
||||
- Diagnostic detail MAY exist, but it MUST be secondary and explicitly revealed.
|
||||
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces rather than the primary content region.
|
||||
- Operators MUST NOT need to parse raw payloads to understand current state or next action.
|
||||
- Detail/status surfaces MUST satisfy DECIDE-AUD-001: decision content
|
||||
first, operator diagnostics second, support/raw evidence third.
|
||||
|
||||
Distinct truth dimensions
|
||||
- When the domain has execution outcome, data completeness, governance result, lifecycle or readiness state, operability truth, health truth, trust/confidence, or next action semantics, the surface MUST keep them explicit instead of collapsing them into one ambiguous status.
|
||||
- If multiple truth dimensions are summarized, the default-visible UI MUST label each dimension clearly.
|
||||
|
||||
Dominant next action and duplicate-truth control
|
||||
- Default-visible decision content MUST include status, reason,
|
||||
impact, and one dominant next action where those concepts exist.
|
||||
- Secondary navigation or debug helpers MUST remain lower-priority
|
||||
than the dominant decision action.
|
||||
- The same blocker, reason, impact, or next action MUST NOT be
|
||||
repeated across multiple default-visible cards, sections, tabs, or
|
||||
summaries.
|
||||
|
||||
Explicit mutation scope
|
||||
- Every state-changing action MUST communicate before execution whether it affects TenantPilot only, the Microsoft tenant, or simulation only.
|
||||
- Mutation scope MUST be understandable from nearby action copy, helper text, preview, or confirmation.
|
||||
@ -1460,13 +1303,6 @@ #### Operator Surface Principles (OPSURF-001)
|
||||
Page contract requirement
|
||||
- Every new or materially refactored operator-facing page MUST define the primary persona, surface type, primary operator question, default-visible information, diagnostics-only information, status dimensions used, mutation scope, primary actions, and dangerous actions.
|
||||
- The page contract MUST live in the governing spec and stay in sync with implementation.
|
||||
- Where multiple audience classes share the page, the contract MUST
|
||||
explicitly define the customer/read-only default path, operator
|
||||
diagnostics path, support/raw-evidence path, and the capabilities
|
||||
that unlock each layer.
|
||||
- The page contract MUST also make the dominant next action,
|
||||
duplicate-truth prevention, and raw/support gating explicit for
|
||||
changed detail/status surfaces.
|
||||
|
||||
#### Spec Scope Fields (SCOPE-002)
|
||||
|
||||
@ -1491,11 +1327,8 @@ #### Enforcement Model (UI-REVIEW-001)
|
||||
native, custom, or a shared detail family, what shared core vs host
|
||||
variation exists if relevant, which layer owns the relevant shell,
|
||||
page, and detail truth, which requested/active/draft/inspect/
|
||||
restorable roles exist, which audience ladder and disclosure
|
||||
boundaries exist, what the dominant next action is, how raw/support
|
||||
evidence is gated, how duplicate truth is prevented, whether any
|
||||
fake-native or host-drift risk is present, and whether an exception
|
||||
type is used.
|
||||
restorable roles exist, whether any fake-native or host-drift risk is
|
||||
present, and whether an exception type is used.
|
||||
- Missing any of those answers makes the spec incomplete.
|
||||
|
||||
PR review requirements
|
||||
@ -1510,12 +1343,7 @@ #### Enforcement Model (UI-REVIEW-001)
|
||||
promoted into primary navigation without justification, one case
|
||||
fragmented across multiple equal-rank pages, new automation that adds
|
||||
attention surfaces without reducing operator work, noisy default
|
||||
surfaces with no action/watch/reference hierarchy, duplicate visible
|
||||
blocker/reason/next-action summaries, customer/operator default paths
|
||||
that expose raw JSON, fingerprints, reason ownership, platform reason
|
||||
families, or monitoring detail, helper actions such as `Open
|
||||
operation`, `Technical details`, or `Show JSON` competing with the
|
||||
dominant decision action, `Filament Costume`,
|
||||
surfaces with no action/watch/reference hierarchy, `Filament Costume`,
|
||||
`Blade Request UI`, `Hand-Rolled Simple Overview`, `Hidden Exception`,
|
||||
`Host Drift`, `State Layer Collapse`, `Parallel Inspect Worlds`, or
|
||||
undocumented exceptions without dedicated tests.
|
||||
@ -1527,15 +1355,11 @@ #### Enforcement Model (UI-REVIEW-001)
|
||||
presence of explicit Inspect on Queue / Review and History / Audit
|
||||
surfaces, absence of empty `ActionGroup` or `BulkActionGroup`,
|
||||
correct placement of destructive actions, truthful scope signals,
|
||||
stable canonical nouns across shells, presence of a single dominant
|
||||
next action where surface metadata exposes one, absence of duplicate
|
||||
visible decision summaries, explicit raw/support gating or secondary
|
||||
placement where the surface serves multiple audience classes,
|
||||
absence of fake-native primary controls where metadata says the
|
||||
surface is native, bounded shared family contracts where metadata
|
||||
says a family is reused, explicit state ownership where specs or
|
||||
metadata expose it, and dedicated tests for every approved
|
||||
exception.
|
||||
stable canonical nouns across shells, absence of fake-native primary
|
||||
controls where metadata says the surface is native, bounded shared
|
||||
family contracts where metadata says a family is reused, explicit
|
||||
state ownership where specs or metadata expose it, and dedicated
|
||||
tests for every approved exception.
|
||||
|
||||
#### Immediate Retrofit Priorities
|
||||
|
||||
@ -1602,10 +1426,6 @@ #### Appendix A - One-page Condensed Constitution
|
||||
- Scope chips must be truthful.
|
||||
- Domain nouns are canonical and stable.
|
||||
- Critical operational truth is default-visible.
|
||||
- Multi-audience detail/status surfaces keep customer-readable decision
|
||||
content above operator diagnostics and support/raw evidence.
|
||||
- One dominant next action stays visually primary.
|
||||
- Duplicate visible decision truth is forbidden.
|
||||
- Semantic truth dimensions are not collapsed into a generic status.
|
||||
- Standard lists stay scanable.
|
||||
- Exceptions are catalogued, justified, and tested.
|
||||
@ -1618,8 +1438,6 @@ #### Appendix B - Feature Review Checklist
|
||||
- The human-in-the-loop moment is explicit.
|
||||
- Immediate-visible decision information is explicit.
|
||||
- On-demand evidence / diagnostics boundaries are explicit.
|
||||
- Audience-aware default visibility and raw-evidence boundaries are
|
||||
explicit where the page serves more than one audience class.
|
||||
- Any new primary surface is justified against an existing decision
|
||||
context.
|
||||
- Navigation reflects a workflow rather than storage structure.
|
||||
@ -1629,8 +1447,6 @@ #### Appendix B - Feature Review Checklist
|
||||
- Broad action-surface class is declared.
|
||||
- Detailed surface type is declared.
|
||||
- The one most likely next operator action is explicit.
|
||||
- One dominant next action stays primary.
|
||||
- Duplicate visible decision truth is absent.
|
||||
- The surface is classified correctly as native, custom, or shared
|
||||
family.
|
||||
- Primary inspect/open model is defined.
|
||||
@ -1712,10 +1528,6 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
|
||||
- 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.
|
||||
- Local Blade/Tailwind cards are allowed only when they preserve dark
|
||||
mode correctness, spacing consistency, badge semantics, action
|
||||
hierarchy, progressive disclosure, accessibility, and overall
|
||||
Filament visual language.
|
||||
|
||||
Native-by-default classification
|
||||
- `Native Surface` means the primary interaction contract is built from
|
||||
@ -1747,8 +1559,6 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||
more than one host, it becomes a `Shared Detail Micro-UI` and MUST
|
||||
define shared core vs host variation before another host reassembles
|
||||
it locally.
|
||||
- Local one-off markup MUST NOT recreate decision/diagnostics/raw
|
||||
layering when an existing shared detail family is sufficient.
|
||||
|
||||
Upgrade-safe preference
|
||||
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
||||
@ -1762,9 +1572,7 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||
- and the deviation is justified briefly in code and in the governing spec or PR.
|
||||
- Approved exceptions MUST stay layout-neutral, use the minimum local
|
||||
classes necessary, MUST NOT invent a new page-local status language,
|
||||
MUST preserve dark mode correctness, spacing consistency,
|
||||
badge semantics, action hierarchy, progressive disclosure,
|
||||
accessibility, and MUST say what remains standardized.
|
||||
and MUST say what remains standardized.
|
||||
- `Hidden Exception` is forbidden. Historical accident or local
|
||||
implementation convenience is not a valid substitute for UI-EX-001.
|
||||
|
||||
@ -1773,8 +1581,6 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||
- which native Filament element or shared primitive was used,
|
||||
- why an existing component was insufficient if an exception was taken,
|
||||
- whether the surface is native, custom, or a shared detail family,
|
||||
- whether any local Blade/Tailwind card still preserves Filament
|
||||
visual language and disclosure semantics,
|
||||
- and whether any ad-hoc status, emphasis styling, or fake-native
|
||||
contract was introduced.
|
||||
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
||||
@ -1808,16 +1614,6 @@ ### Scope, Compliance, and Review Expectations
|
||||
- Runtime-changing or test-affecting specs and PRs MUST include testing/lane/runtime impact covering actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost changes, any heavy-family expansion, expected budget/baseline/trend effect, escalation decisions, and the minimal validation commands.
|
||||
- Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge.
|
||||
- Specs and PRs that touch shared provider/platform seams MUST classify the touched boundary as provider-owned or platform-core, keep provider-specific semantics out of platform-core contracts and vocabulary unless explicitly justified, and record whether any remaining hotspot is resolved in-feature or escalated as a follow-up spec.
|
||||
- Specs and PRs that create, queue, deduplicate, resume, block, complete, or deep-link to an `OperationRun` MUST reuse the
|
||||
central OperationRun Start UX Contract, keep queued DB notifications explicit opt-in unless the active spec states a
|
||||
different policy, route terminal notifications through the lifecycle mechanism, include an `OperationRun UX Impact`
|
||||
section in the active spec or plan, and document any temporary exception with an architecture note, test rationale,
|
||||
and migration decision.
|
||||
- Specs and PRs that change detail or status surfaces MUST explicitly
|
||||
document how they satisfy customer-readable decision-first content,
|
||||
diagnostics-secondary disclosure, support/raw-evidence gating, one
|
||||
dominant next action, duplicate-truth prevention, and shared-pattern
|
||||
reuse.
|
||||
- Specs and PRs that change operator-facing surfaces MUST classify each
|
||||
affected surface under DECIDE-001 and justify any new Primary
|
||||
Decision Surface or workflow-first navigation change.
|
||||
@ -1835,4 +1631,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 2.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-27
|
||||
**Version**: 2.9.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-23
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# Research T186 — settings_apply capability verification (LEGACY / DEPRECATED)
|
||||
|
||||
> **Status:** Superseded
|
||||
> **Last reviewed:** 2026-04-30
|
||||
> **Use for:** Historical investigation context only if a later Settings Catalog write-path regression needs provenance
|
||||
> **Do not use for:** Active feature research or current implementation truth
|
||||
|
||||
> DEPRECATED: Do not add new research notes under `.specify/`.
|
||||
> Active feature research should live under `specs/<NNN>-<slug>/`.
|
||||
> Legacy history lives under `spechistory/`.
|
||||
|
||||
@ -32,13 +32,6 @@ ## Shared Pattern Reuse
|
||||
- [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control.
|
||||
- [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded.
|
||||
|
||||
## OperationRun Start UX Contract
|
||||
|
||||
- [ ] CHK019 The change explicitly says whether it creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`, and the required `OperationRun UX Impact` section exists when applicable.
|
||||
- [ ] CHK020 Queued toast/link/artifact-link/browser-event/dedupe-or-blocked messaging and tenant/workspace-safe operation URL resolution are delegated to the shared OperationRun UX contract instead of local surface code.
|
||||
- [ ] CHK021 Any queued DB notification is explicit opt-in in the active spec or plan, running DB notifications remain absent, and terminal notifications still flow through the central lifecycle mechanism.
|
||||
- [ ] CHK022 Any exception records the explicit spec decision, architecture note, test or guard-test rationale, and temporary migration follow-up decision.
|
||||
|
||||
## Provider Boundary And Vocabulary
|
||||
|
||||
- [ ] CHK010 The change states whether any touched shared seam is provider-owned, platform-core, or mixed, and provider-specific semantics do not silently spread into platform-core contracts, taxonomy, identifiers, compare semantics, or operator vocabulary.
|
||||
@ -51,14 +44,6 @@ ## Signals, Exceptions, And Test Depth
|
||||
- [ ] CHK014 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
||||
- [ ] CHK015 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
||||
|
||||
## Audience-Aware Disclosure And Decision Hierarchy
|
||||
|
||||
- [ ] CHK023 Default-visible content is decision-first and clearly separated from operator diagnostics and support/raw evidence.
|
||||
- [ ] CHK024 Customer/read-only default paths do not expose raw JSON, copied context payloads, fingerprints, internal reason ownership, platform reason families, monitoring detail, or other debug semantics by default.
|
||||
- [ ] CHK025 Exactly one dominant next action is primary; navigation or debug helpers such as `Open operation`, `Technical details`, or `Show JSON` do not compete at equal weight.
|
||||
- [ ] CHK026 Duplicate visible status, blocker, reason, impact, or next-action summaries are removed or explicitly justified as non-duplicative evidence.
|
||||
- [ ] CHK027 Support/raw sections are collapsed, lower-priority, or capability-gated where applicable, and any local Blade/Tailwind surface still preserves Filament visual language, dark mode correctness, progressive disclosure, and accessibility.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- [ ] CHK016 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
||||
|
||||
@ -36,10 +36,6 @@ ## UI / Surface Guardrail Plan
|
||||
- **Native vs custom classification summary**: [native / custom / mixed / N/A]
|
||||
- **Shared-family relevance**: [none / list affected shared families]
|
||||
- **State layers in scope**: [shell / page / detail / URL-query / none]
|
||||
- **Audience modes in scope**: [customer/read-only / operator-MSP / support-platform / N/A]
|
||||
- **Decision/diagnostic/raw hierarchy plan**: [decision-first / diagnostics-second / support-raw-third / N/A]
|
||||
- **Raw/support gating plan**: [collapsed / capability-gated / role-gated / N/A]
|
||||
- **One-primary-action / duplicate-truth control**: [how one dominant next action is preserved and repeated blockers are removed]
|
||||
- **Handling modes by drift class or surface**: [hard-stop-candidate / review-mandatory / exception-required / report-only / N/A]
|
||||
- **Repository-signal treatment**: [report-only / review-mandatory / exception-required / future hard-stop candidate / N/A]
|
||||
- **Special surface test profiles**: [standard-native-filament / shared-detail-family / monitoring-state-page / global-context-shell / exception-coded-surface / N/A]
|
||||
@ -58,18 +54,6 @@ ## Shared Pattern & System Fit
|
||||
- **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth]
|
||||
- **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule]
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
> **Fill when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`. Docs-only or template-only work may use concise `N/A`.**
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: [yes / no / N/A]
|
||||
- **Central contract reused**: [shared OperationRun UX layer / `N/A`]
|
||||
- **Delegated UX behaviors**: [queued toast / run link / artifact link / run-enqueued browser event / queued DB-notification decision / dedupe-or-blocked messaging / tenant/workspace-safe URL resolution / `N/A`]
|
||||
- **Surface-owned behavior kept local**: [initiation inputs only / none / short explanation]
|
||||
- **Queued DB-notification policy**: [explicit opt-in / spec override / `N/A`]
|
||||
- **Terminal notification path**: [central lifecycle mechanism / `N/A`]
|
||||
- **Exception path**: [none / spec decision + architecture note + test rationale + temporary migration follow-up]
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
> **Fill when the feature touches shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth. Docs-only or template-only work may use concise `N/A`.**
|
||||
@ -95,8 +79,7 @@ ## Constitution Check
|
||||
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
|
||||
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
||||
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
|
||||
- OperationRun start UX: any feature that creates, queues, deduplicates, resumes, blocks, completes, or links `OperationRun` reuses the central OperationRun Start UX Contract; no local composition of queued toast/link/event/start-state messaging; `OperationRun UX Impact` is present in the active spec or plan
|
||||
- Ops-UX 3-surface feedback: if `OperationRun` is used, default feedback is toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); queued DB notifications remain explicit opt-in through the shared start UX contract; running DB notifications stay disallowed
|
||||
- Ops-UX 3-surface feedback: if `OperationRun` is used, feedback is exactly toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); no queued/running DB notifications
|
||||
- Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside
|
||||
- Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only
|
||||
- Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress
|
||||
@ -115,10 +98,6 @@ ## Constitution Check
|
||||
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||
- 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): if local Blade/Tailwind cards are
|
||||
still necessary, they preserve dark mode correctness, spacing
|
||||
consistency, badge semantics, action hierarchy, progressive
|
||||
disclosure, accessibility, and Filament visual language
|
||||
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
|
||||
- Decision-first operating model (DECIDE-001): each changed
|
||||
operator-facing surface is classified as Primary Decision,
|
||||
@ -128,13 +107,6 @@ ## Constitution Check
|
||||
disclosed, one governance case stays decidable in one context where
|
||||
practical, navigation follows workflows not storage structures, and
|
||||
automation / alerts reduce attention load instead of adding noise
|
||||
- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): detail or
|
||||
status surfaces separate customer-readable decision content,
|
||||
operator diagnostics, and support/raw evidence; customer-readable
|
||||
default paths hide raw JSON, copied context, fingerprints, internal
|
||||
reason ownership, platform reason families, and debug semantics;
|
||||
one dominant next action is explicit; duplicate visible truth is
|
||||
removed
|
||||
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
|
||||
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): standard CRUD and Registry rows expose at most one inline safe shortcut; destructive actions are grouped or in the detail header; queue exceptions are catalogued, justified, and tested
|
||||
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): scope signals are truthful, canonical nouns stay stable across shells, critical operational truth is default-visible, and standard lists remain scanable
|
||||
|
||||
@ -47,16 +47,6 @@ ## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches not
|
||||
- **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links]
|
||||
- **Review focus**: [What reviewers must verify to prevent parallel local patterns]
|
||||
|
||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: [yes/no]
|
||||
- **Shared OperationRun UX contract/layer reused**: [Name it or `N/A`]
|
||||
- **Delegated start/completion UX behaviors**: [queued toast / `Open operation` or `View run` link / artifact link / run-enqueued browser event / queued DB-notification decision / dedupe-or-blocked messaging / tenant/workspace-safe URL resolution / `N/A`]
|
||||
- **Local surface-owned behavior that remains**: [initiation inputs only / none / bounded explanation]
|
||||
- **Queued DB-notification policy**: [explicit opt-in / spec override / `N/A`]
|
||||
- **Terminal notification path**: [central lifecycle mechanism / `N/A`]
|
||||
- **Exception required?**: [none / explicit spec decision + architecture note + test or guard-test rationale + temporary migration follow-up]
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: [yes/no]
|
||||
@ -89,17 +79,6 @@ ## Decision-First Surface Role *(mandatory when operator-facing surfaces are cha
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| e.g. Review inbox | Primary Decision Surface | Review and release queued governance work | Case summary, severity, recommendation, required action | Full evidence, raw payloads, audit trail, provider diagnostics | Primary because it is the queue where operators decide and clear work | Follows pending-decisions workflow, not storage objects | Removes search across runs, findings, and audit pages |
|
||||
|
||||
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
If this feature adds or materially changes a detail or status surface,
|
||||
fill out one row per affected surface. Reuse the same surface names
|
||||
used above and make the disclosure hierarchy explicit instead of
|
||||
assuming it.
|
||||
|
||||
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| e.g. Review inbox | customer-read-only, operator-MSP, support-platform | Current status, why it matters, impact, recommendation, next action | Review history, lifecycle, related evidence, related runs | Raw payloads, fingerprints, reason ownership, platform reason family | `Review evidence` | Raw/support detail hidden or capability-gated outside support mode | The top summary states the blocker once; later sections add evidence rather than restating it |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
||||
@ -265,13 +244,6 @@ ## Requirements *(mandatory)*
|
||||
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
|
||||
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
|
||||
|
||||
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** If this feature changes a detail or status surface, the spec MUST describe:
|
||||
- how the surface separates customer-readable decision content, operator diagnostics, and support/raw evidence,
|
||||
- which audience modes are in scope (`customer/read-only`, `operator/MSP`, `support/platform`),
|
||||
- which content is hidden, collapsed, or capability-gated by default,
|
||||
- how one dominant next action is preserved,
|
||||
- and how duplicate visible truth is prevented.
|
||||
|
||||
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
|
||||
- classify each touched seam as provider-owned or platform-core,
|
||||
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
|
||||
@ -291,21 +263,12 @@ ## Requirements *(mandatory)*
|
||||
- and the exact minimal validation commands reviewers should run.
|
||||
|
||||
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
||||
- explicitly state compliance with the default Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification) and whether any queued DB notification is explicitly opted into,
|
||||
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
|
||||
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
|
||||
- describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules,
|
||||
- clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring),
|
||||
- list which regression guard tests are added/updated to keep these rules enforceable in CI.
|
||||
|
||||
**Constitution alignment (OPS-UX-START-001):** If this feature creates, queues, deduplicates, resumes, blocks, completes, or links to an `OperationRun`, the spec MUST:
|
||||
- include the `OperationRun UX Impact` section,
|
||||
- name the shared OperationRun UX contract/layer being reused,
|
||||
- delegate queued toast/link/artifact-link/browser-event/queued-DB-notification/dedupe-or-blocked messaging/tenant-safe URL resolution to that shared path,
|
||||
- keep local surface code limited to initiation inputs and operation-specific data capture,
|
||||
- keep queued DB notifications explicit opt-in unless the spec intentionally defines a different policy,
|
||||
- route terminal notifications through the central lifecycle mechanism,
|
||||
- and document any exception with an explicit spec decision, architecture note, test or guard-test rationale, and temporary follow-up migration decision.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||
- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
|
||||
- ensure any cross-plane access is deny-as-not-found (404),
|
||||
@ -328,7 +291,6 @@ ## Requirements *(mandatory)*
|
||||
- which native Filament components or shared UI primitives are used,
|
||||
- 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 any required local Blade/Tailwind cards still preserve dark mode correctness, spacing consistency, badge semantics, action hierarchy, progressive disclosure, accessibility, and Filament visual language,
|
||||
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||
@ -386,7 +348,6 @@ ## Requirements *(mandatory)*
|
||||
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
||||
- which diagnostics are secondary and how they are explicitly revealed,
|
||||
- how the dominant next action stays primary and how duplicate visible truth is avoided,
|
||||
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
||||
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
||||
|
||||
@ -18,22 +18,17 @@ # Tasks: [FEATURE NAME]
|
||||
- record budget, baseline, or trend follow-up when runtime cost shifts materially,
|
||||
- and document whether the change resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
||||
**Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a
|
||||
canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub through the shared OperationRun start UX path rather than local surface composition.
|
||||
canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub.
|
||||
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
|
||||
Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints
|
||||
without an `OperationRun`.
|
||||
If this feature creates/reuses an `OperationRun`, tasks MUST also include:
|
||||
- reusing the central OperationRun Start UX Contract instead of composing local queued toast/link/event/dedupe/blocked/start-failure semantics,
|
||||
- delegating `Open operation` / `View run`, artifact links, run-enqueued browser event, queued DB-notification policy, dedupe / already-available / already-running messaging, blocked / failed-to-start messaging, and tenant/workspace-safe URL resolution to the shared OperationRun UX layer,
|
||||
- enforcing the Ops-UX 3-surface feedback contract (toast intent-only via `OperationUxPresenter`, progress only in widget + run detail, terminal notification is `OperationRunCompleted` exactly-once, initiator-only),
|
||||
- keeping queued DB notifications explicit opt-in in the active spec unless a different policy is intentionally approved, and ensuring running DB notifications do not exist,
|
||||
- routing terminal notifications through the central lifecycle mechanism rather than feature-local notification code,
|
||||
- ensuring no queued/running DB notifications exist anywhere for operations (no `sendToDatabase()` for queued/running/completion/abort in feature code),
|
||||
- ensuring `OperationRun.status` / `OperationRun.outcome` transitions happen only via `OperationRunService`,
|
||||
- ensuring `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only,
|
||||
- adding/updating Ops-UX regression guards (Pest) that fail CI with actionable output (file + snippet) when these patterns regress,
|
||||
- clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system),
|
||||
- documenting any exception with an explicit spec decision, architecture note, test or guard-test rationale, and temporary migration follow-up decision,
|
||||
- and ensuring the active spec or plan contains an `OperationRun UX Impact` section.
|
||||
- clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system).
|
||||
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
|
||||
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
|
||||
- explicit 404 vs 403 semantics:
|
||||
@ -78,21 +73,9 @@ # Tasks: [FEATURE NAME]
|
||||
- filling the spec’s Operator Surface Contract for every affected page,
|
||||
- keeping default-visible content limited to first-decision needs and
|
||||
moving proof, payloads, and diagnostics into progressive disclosure,
|
||||
- implementing the three-tier disclosure hierarchy where applicable:
|
||||
customer-readable decision content first, operator diagnostics
|
||||
second, support/raw evidence third,
|
||||
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
||||
- ensuring customer/read-only default paths do not expose raw JSON,
|
||||
copied context payloads, fingerprints, internal reason ownership,
|
||||
platform reason families, or debug semantics,
|
||||
- keeping each governance case decidable in one focused context where
|
||||
practical instead of forcing cross-page reconstruction,
|
||||
- keeping exactly one dominant next action primary and demoting
|
||||
navigation/debug helpers such as `Open operation`, `Technical
|
||||
details`, or `Show JSON`,
|
||||
- removing duplicate visible status, blocker, reason, impact, or
|
||||
next-action summaries so later sections add evidence instead of
|
||||
restating the same decision truth,
|
||||
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
||||
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
||||
@ -140,12 +123,6 @@ # Tasks: [FEATURE NAME]
|
||||
- documenting any catalogued UI exception in the spec/PR and adding dedicated test coverage,
|
||||
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||
- For any new or modified customer/operator-facing detail surface,
|
||||
tests MUST prove default-visible status/reason/impact/next-action
|
||||
content, exactly one dominant next action, diagnostics-secondary
|
||||
ordering, hidden raw/support detail by default, capability-gated
|
||||
support/raw sections where applicable, and the absence of duplicate
|
||||
visible decision summaries.
|
||||
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
||||
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
||||
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
|
||||
|
||||
@ -59,13 +59,6 @@ MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
SUPPORT_DESK_ENABLED=false
|
||||
SUPPORT_DESK_NAME="External support desk"
|
||||
SUPPORT_DESK_CREATE_URL=
|
||||
SUPPORT_DESK_API_TOKEN=
|
||||
SUPPORT_DESK_TICKET_URL_TEMPLATE=
|
||||
SUPPORT_DESK_TIMEOUT_SECONDS=5
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ProductUsageEvent;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PruneProductUsageEventsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'tenantpilot:product-usage:prune {--days= : Number of days to retain product usage events}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete product usage events older than the retention period';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) ($this->option('days') ?: config('tenantpilot.product_usage_event_retention_days', 90));
|
||||
|
||||
if ($days < 1) {
|
||||
$this->error('Retention days must be at least 1.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$deleted = ProductUsageEvent::query()
|
||||
->where('occurred_at', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$this->info("Deleted {$deleted} product usage event(s) older than {$days} days.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -6,14 +6,12 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PurgeLegacyBaselineGapRuns extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
|
||||
{--type=* : Limit cleanup to baseline.compare and/or baseline.capture runs (legacy aliases also accepted)}
|
||||
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
|
||||
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
|
||||
{--workspace=* : Limit cleanup to workspace ids}
|
||||
{--limit=500 : Maximum candidate runs to inspect}
|
||||
@ -101,35 +99,21 @@ public function handle(): int
|
||||
*/
|
||||
private function normalizedTypes(): array
|
||||
{
|
||||
$requestedTypes = array_values(array_unique(array_filter(
|
||||
$types = array_values(array_unique(array_filter(
|
||||
array_map(
|
||||
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
|
||||
(array) $this->option('type'),
|
||||
),
|
||||
)));
|
||||
|
||||
$canonicalTypes = array_values(array_unique(array_filter(array_map(
|
||||
static fn (string $type): ?string => match ($type) {
|
||||
OperationRunType::BaselineCompare->value, 'baseline_compare' => OperationRunType::BaselineCompare->value,
|
||||
OperationRunType::BaselineCapture->value, 'baseline_capture' => OperationRunType::BaselineCapture->value,
|
||||
default => null,
|
||||
},
|
||||
$requestedTypes,
|
||||
))));
|
||||
|
||||
if ($canonicalTypes === []) {
|
||||
$canonicalTypes = [
|
||||
OperationRunType::BaselineCompare->value,
|
||||
OperationRunType::BaselineCapture->value,
|
||||
];
|
||||
if ($types === []) {
|
||||
return ['baseline_compare', 'baseline_capture'];
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_merge(
|
||||
...array_map(
|
||||
static fn (string $type): array => OperationCatalog::rawValuesForCanonical($type),
|
||||
$canonicalTypes,
|
||||
),
|
||||
)));
|
||||
return array_values(array_filter(
|
||||
$types,
|
||||
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TenantpilotBackfillFindingLifecycle extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:findings:backfill-lifecycle
|
||||
{--tenant=* : Limit to tenant_id/external_id}';
|
||||
|
||||
protected $description = 'Queue tenant-scoped findings lifecycle backfill jobs idempotently.';
|
||||
|
||||
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
|
||||
{
|
||||
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||
|
||||
if ($tenantIdentifiers === []) {
|
||||
$this->error('Provide one or more tenants via --tenant={id|external_id}.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$tenants = $this->resolveTenants($tenantIdentifiers);
|
||||
|
||||
if ($tenants->isEmpty()) {
|
||||
$this->info('No tenants matched the provided identifiers.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$queued = 0;
|
||||
$skipped = 0;
|
||||
$nothingToDo = 0;
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
if (! $tenant instanceof Tenant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$run = $runbookService->start(
|
||||
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||
initiator: null,
|
||||
reason: null,
|
||||
source: 'cli',
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
$errors = $e->errors();
|
||||
|
||||
if (isset($errors['preflight.affected_count'])) {
|
||||
$nothingToDo++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->error(sprintf(
|
||||
'Backfill blocked for tenant %d: %s',
|
||||
(int) $tenant->getKey(),
|
||||
$e->getMessage(),
|
||||
));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $run->wasRecentlyCreated) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$queued++;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Queued %d backfill run(s), skipped %d duplicate run(s), nothing to do %d.',
|
||||
$queued,
|
||||
$skipped,
|
||||
$nothingToDo,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tenantIdentifiers
|
||||
* @return \Illuminate\Support\Collection<int, Tenant>
|
||||
*/
|
||||
private function resolveTenants(array $tenantIdentifiers)
|
||||
{
|
||||
$tenantIds = [];
|
||||
|
||||
foreach ($tenantIdentifiers as $identifier) {
|
||||
$tenant = Tenant::query()
|
||||
->forTenant($identifier)
|
||||
->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$tenantIds[] = (int) $tenant->getKey();
|
||||
}
|
||||
}
|
||||
|
||||
$tenantIds = array_values(array_unique($tenantIds));
|
||||
|
||||
if ($tenantIds === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
->whereIn('id', $tenantIds)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@ -51,7 +50,7 @@ public function handle(): int
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRunWithIdentityStrict(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::DirectoryGroupsSync->value,
|
||||
type: 'entra_group_sync',
|
||||
identityInputs: [
|
||||
'selection_key' => $selectionKey,
|
||||
'slot_key' => $slotKey,
|
||||
|
||||
@ -11,7 +11,6 @@
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -169,12 +168,12 @@ private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
||||
'tenant_id' => (int) $tenant->id,
|
||||
'user_id' => null,
|
||||
'initiator_name' => 'System',
|
||||
'type' => OperationRunType::BackupSchedulePurge->value,
|
||||
'type' => 'backup_schedule_purge',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'run_identity_hash' => hash('sha256', implode(':', [
|
||||
(string) $tenant->id,
|
||||
OperationRunType::BackupSchedulePurge->value,
|
||||
'backup_schedule_purge',
|
||||
now()->toISOString(),
|
||||
Str::uuid()->toString(),
|
||||
])),
|
||||
|
||||
@ -7,9 +7,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\OperationLifecycleReconciler;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||
@ -30,7 +28,7 @@ public function handle(
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$query = OperationRun::query()
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value))
|
||||
->where('type', 'backup_schedule_run')
|
||||
->whereIn('status', ['queued', 'running']);
|
||||
|
||||
if ($olderThanMinutes > 0) {
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Services\Runbooks\RunbookReason;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TenantpilotRunDeployRunbooks extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:run-deploy-runbooks';
|
||||
|
||||
protected $description = 'Run deploy-time runbooks idempotently.';
|
||||
|
||||
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
|
||||
{
|
||||
try {
|
||||
$runbookService->start(
|
||||
scope: FindingsLifecycleBackfillScope::allTenants(),
|
||||
initiator: null,
|
||||
reason: new RunbookReason(
|
||||
reasonCode: RunbookReason::CODE_DATA_REPAIR,
|
||||
reasonText: 'Deploy hook automated runbooks',
|
||||
),
|
||||
source: 'deploy_hook',
|
||||
);
|
||||
|
||||
$this->info('Deploy runbooks started (if needed).');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (ValidationException $e) {
|
||||
$errors = $e->errors();
|
||||
|
||||
$skippable = isset($errors['preflight.affected_count']) || isset($errors['scope']);
|
||||
|
||||
if ($skippable) {
|
||||
$this->info('Deploy runbooks skipped (nothing to do or already running).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->error('Deploy runbooks blocked by validation errors.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Entitlements;
|
||||
|
||||
final class WorkspaceEntitlementBlockedException extends \RuntimeException
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $decision
|
||||
*/
|
||||
public function __construct(private readonly array $decision)
|
||||
{
|
||||
parent::__construct((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks this action.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function decision(): array
|
||||
{
|
||||
return $this->decision;
|
||||
}
|
||||
}
|
||||
@ -9,9 +9,4 @@
|
||||
class Login extends BaseLogin
|
||||
{
|
||||
protected string $view = 'filament.pages.auth.login';
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('localization.auth.sign_in_microsoft');
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,6 @@
|
||||
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
@ -490,7 +489,7 @@ private function compareNowAction(): Action
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : OperationRunType::BaselineCompare->value)
|
||||
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare')
|
||||
->actions($run instanceof OperationRun ? [
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
|
||||
@ -18,7 +18,6 @@
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
@ -811,8 +810,8 @@ private function compareAssignedTenants(): void
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
$toast = (int) $result['queuedCount'] > 0
|
||||
? OperationUxPresenter::queuedToast(OperationRunType::BaselineCompare->value)
|
||||
: OperationUxPresenter::alreadyQueuedToast(OperationRunType::BaselineCompare->value);
|
||||
? OperationUxPresenter::queuedToast('baseline_compare')
|
||||
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
|
||||
|
||||
$toast
|
||||
->body($summary.' Open Operations for progress and next steps.')
|
||||
|
||||
@ -1,674 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
|
||||
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
||||
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
|
||||
class CrossTenantComparePage extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
private const string SOURCE_TENANT_QUERY_KEY = 'source_tenant_id';
|
||||
|
||||
private const string TARGET_TENANT_QUERY_KEY = 'target_tenant_id';
|
||||
|
||||
private const string POLICY_TYPE_QUERY_KEY = 'policy_type';
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $title = 'Cross-Tenant Compare';
|
||||
|
||||
protected static ?string $slug = 'cross-tenant-compare';
|
||||
|
||||
protected string $view = 'filament.pages.cross-tenant-compare';
|
||||
|
||||
public ?string $sourceTenantId = null;
|
||||
|
||||
public ?string $targetTenantId = null;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $selectedPolicyTypes = [];
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $navigationContextPayload = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $preview = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $preflight = null;
|
||||
|
||||
public ?string $selectionMessage = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve return navigation, tenant drill-downs, and one dominant promotion-preflight action.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'The compare page uses explicit selection controls instead of row-click inspection.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Cross-tenant compare renders focused subject summaries instead of row-level overflow actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The compare page has no bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The compare page explains when a selection is incomplete or invalid before any preview exists.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Cross-tenant compare is a workspace decision page, not a record detail header.');
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
|
||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||
|
||||
$this->hydrateSelectionFromRequest();
|
||||
$this->refreshPreview();
|
||||
|
||||
$this->form->fill($this->formState());
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Grid::make([
|
||||
'default' => 1,
|
||||
'xl' => 3,
|
||||
])
|
||||
->schema([
|
||||
Select::make('sourceTenantId')
|
||||
->label('Source tenant')
|
||||
->options(fn (): array => $this->tenantOptions())
|
||||
->searchable()
|
||||
->preload()
|
||||
->native(false)
|
||||
->placeholder('Select a source tenant')
|
||||
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-source'])
|
||||
->extraInputAttributes(['data-testid' => 'cross-tenant-source']),
|
||||
Select::make('targetTenantId')
|
||||
->label('Target tenant')
|
||||
->options(fn (): array => $this->tenantOptions())
|
||||
->searchable()
|
||||
->preload()
|
||||
->native(false)
|
||||
->placeholder('Select a target tenant')
|
||||
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-target'])
|
||||
->extraInputAttributes(['data-testid' => 'cross-tenant-target']),
|
||||
Select::make('selectedPolicyTypes')
|
||||
->label('Governed subjects')
|
||||
->options(fn (): array => $this->policyTypeOptions())
|
||||
->multiple()
|
||||
->searchable()
|
||||
->preload()
|
||||
->native(false)
|
||||
->placeholder('All governed subjects')
|
||||
->helperText(fn (): ?string => $this->policyTypeOptions() === []
|
||||
? 'Governed subject filters appear after authorized tenant inventory exists in the active workspace.'
|
||||
: null)
|
||||
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-policy-types'])
|
||||
->extraInputAttributes(['data-testid' => 'cross-tenant-policy-types']),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
$navigationContext = $this->navigationContext();
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('return_to_origin')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl);
|
||||
}
|
||||
|
||||
$sourceTenant = $this->selectedSourceTenant();
|
||||
|
||||
if ($sourceTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('open_source_tenant')
|
||||
->label('Open source tenant')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url(TenantResource::getUrl('view', ['record' => $sourceTenant], panel: 'admin'));
|
||||
}
|
||||
|
||||
$targetTenant = $this->selectedTargetTenant();
|
||||
|
||||
if ($targetTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('open_target_tenant')
|
||||
->label('Open target tenant')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url(TenantResource::getUrl('view', ['record' => $targetTenant], panel: 'admin'));
|
||||
}
|
||||
|
||||
$preflightAction = Action::make('generatePromotionPreflight')
|
||||
->label('Generate promotion preflight')
|
||||
->icon('heroicon-o-sparkles')
|
||||
->color('primary')
|
||||
->disabled(fn (): bool => $this->preflightDisabledReason() !== null)
|
||||
->tooltip(fn (): ?string => $this->preflightDisabledReason())
|
||||
->action(fn (): mixed => $this->generatePromotionPreflight());
|
||||
|
||||
$preflightAction = WorkspaceUiEnforcement::forAction(
|
||||
$preflightAction,
|
||||
fn (): ?Workspace => $this->workspace(),
|
||||
)
|
||||
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||
->preserveDisabled()
|
||||
->tooltip('You need workspace baseline manage access to generate a promotion preflight.')
|
||||
->apply()
|
||||
->tooltip(function (): ?string {
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if ($user instanceof User && $workspace instanceof Workspace) {
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if ($resolver->isMember($user, $workspace)
|
||||
&& ! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
||||
return 'You need workspace baseline manage access to generate a promotion preflight.';
|
||||
}
|
||||
}
|
||||
|
||||
return $this->preflightDisabledReason();
|
||||
});
|
||||
|
||||
$actions[] = $preflightAction;
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function applySelection(): void
|
||||
{
|
||||
$this->selectionMessage = null;
|
||||
$this->preflight = null;
|
||||
|
||||
$this->sourceTenantId = $this->normalizeTenantIdentifier($this->sourceTenantId);
|
||||
$this->targetTenantId = $this->normalizeTenantIdentifier($this->targetTenantId);
|
||||
$this->selectedPolicyTypes = $this->normalizePolicyTypes($this->selectedPolicyTypes);
|
||||
|
||||
if ($this->sourceTenantId !== null
|
||||
&& $this->targetTenantId !== null
|
||||
&& $this->sourceTenantId === $this->targetTenantId) {
|
||||
$this->selectionMessage = 'Choose two different tenants.';
|
||||
$this->addError('targetTenantId', $this->selectionMessage);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->redirect($this->selectionUrl(), navigate: true);
|
||||
}
|
||||
|
||||
public function generatePromotionPreflight(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
$this->authorizePreflightExecution();
|
||||
|
||||
if ($this->preview === null) {
|
||||
$this->refreshPreview();
|
||||
}
|
||||
|
||||
if ($this->preview === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$selection = $this->compareSelection();
|
||||
|
||||
if (! $selection instanceof CrossTenantCompareSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->preflight = app(CrossTenantPromotionPreflight::class)->build($this->preview);
|
||||
|
||||
$workspace = $this->workspace();
|
||||
$user = auth()->user();
|
||||
|
||||
if ($workspace instanceof Workspace && $user instanceof User) {
|
||||
app(WorkspaceAuditLogger::class)->logCrossTenantPromotionPreflightGenerated(
|
||||
workspace: $workspace,
|
||||
sourceTenant: $selection->sourceTenant,
|
||||
targetTenant: $selection->targetTenant,
|
||||
preflight: $this->preflight,
|
||||
actor: $user,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function clearSelectionUrl(): string
|
||||
{
|
||||
return static::getUrl($this->routeParameters([
|
||||
self::SOURCE_TENANT_QUERY_KEY => null,
|
||||
self::TARGET_TENANT_QUERY_KEY => null,
|
||||
self::POLICY_TYPE_QUERY_KEY => null,
|
||||
]), panel: 'admin');
|
||||
}
|
||||
|
||||
public function selectionUrl(): string
|
||||
{
|
||||
return static::getUrl($this->routeParameters(), panel: 'admin');
|
||||
}
|
||||
|
||||
public static function launchUrl(
|
||||
?Tenant $sourceTenant = null,
|
||||
?Tenant $targetTenant = null,
|
||||
?CanonicalNavigationContext $navigationContext = null,
|
||||
): string {
|
||||
$parameters = [];
|
||||
|
||||
if ($sourceTenant instanceof Tenant) {
|
||||
$parameters[self::SOURCE_TENANT_QUERY_KEY] = (int) $sourceTenant->getKey();
|
||||
}
|
||||
|
||||
if ($targetTenant instanceof Tenant) {
|
||||
$parameters[self::TARGET_TENANT_QUERY_KEY] = (int) $targetTenant->getKey();
|
||||
}
|
||||
|
||||
if ($navigationContext instanceof CanonicalNavigationContext) {
|
||||
$parameters = array_replace($parameters, $navigationContext->toQuery());
|
||||
}
|
||||
|
||||
return static::getUrl($parameters, panel: 'admin');
|
||||
}
|
||||
|
||||
public function hasActiveSelection(): bool
|
||||
{
|
||||
return $this->sourceTenantId !== null
|
||||
|| $this->targetTenantId !== null
|
||||
|| $this->selectedPolicyTypes !== [];
|
||||
}
|
||||
|
||||
public function stateColor(string $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
'match', 'ready' => 'success',
|
||||
'different', 'manual_mapping_required' => 'warning',
|
||||
'missing' => 'info',
|
||||
'ambiguous' => 'gray',
|
||||
'blocked' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
public function stateLabel(string $value): string
|
||||
{
|
||||
return Str::headline(str_replace('_', ' ', $value));
|
||||
}
|
||||
|
||||
public function reasonLabel(string $reasonCode): string
|
||||
{
|
||||
return Str::headline(str_replace('_', ' ', $reasonCode));
|
||||
}
|
||||
|
||||
public function sourceTenantUrl(): ?string
|
||||
{
|
||||
$tenant = $this->selectedSourceTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
||||
}
|
||||
|
||||
public function targetTenantUrl(): ?string
|
||||
{
|
||||
$tenant = $this->selectedTargetTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formState(): array
|
||||
{
|
||||
return [
|
||||
'sourceTenantId' => $this->sourceTenantId,
|
||||
'targetTenantId' => $this->targetTenantId,
|
||||
'selectedPolicyTypes' => $this->selectedPolicyTypes,
|
||||
];
|
||||
}
|
||||
|
||||
private function hydrateSelectionFromRequest(): void
|
||||
{
|
||||
$this->sourceTenantId = $this->normalizeTenantIdentifier(request()->query(self::SOURCE_TENANT_QUERY_KEY));
|
||||
$this->targetTenantId = $this->normalizeTenantIdentifier(request()->query(self::TARGET_TENANT_QUERY_KEY));
|
||||
$this->selectedPolicyTypes = $this->normalizePolicyTypes(request()->query(self::POLICY_TYPE_QUERY_KEY, []));
|
||||
}
|
||||
|
||||
private function refreshPreview(): void
|
||||
{
|
||||
$this->selectionMessage = null;
|
||||
$this->preview = null;
|
||||
$this->preflight = null;
|
||||
|
||||
$selection = $this->compareSelection();
|
||||
|
||||
if (! $selection instanceof CrossTenantCompareSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->preview = app(CrossTenantComparePreviewBuilder::class)->build($selection);
|
||||
}
|
||||
|
||||
private function authorizePageAccess(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizePreflightExecution(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
private function compareSelection(): ?CrossTenantCompareSelection
|
||||
{
|
||||
$sourceTenant = $this->selectedSourceTenant();
|
||||
$targetTenant = $this->selectedTargetTenant();
|
||||
|
||||
if (! $sourceTenant instanceof Tenant || ! $targetTenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $sourceTenant->getKey() === (int) $targetTenant->getKey()) {
|
||||
$this->selectionMessage = 'Choose two different tenants.';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return new CrossTenantCompareSelection(
|
||||
sourceTenant: $sourceTenant,
|
||||
targetTenant: $targetTenant,
|
||||
policyTypes: $this->selectedPolicyTypes,
|
||||
);
|
||||
}
|
||||
|
||||
private function selectedSourceTenant(): ?Tenant
|
||||
{
|
||||
if ($this->sourceTenantId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolveAuthorizedTenant($this->sourceTenantId);
|
||||
}
|
||||
|
||||
private function selectedTargetTenant(): ?Tenant
|
||||
{
|
||||
if ($this->targetTenantId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolveAuthorizedTenant($this->targetTenantId);
|
||||
}
|
||||
|
||||
private function resolveAuthorizedTenant(string $tenantId): Tenant
|
||||
{
|
||||
$workspace = $this->workspace();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $workspace instanceof Workspace || ! $user instanceof User) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->whereKey((int) $tenantId)
|
||||
->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $user->canAccessTenant($tenant) || ! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function tenantOptions(): array
|
||||
{
|
||||
$workspace = $this->workspace();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $workspace instanceof Workspace || ! $user instanceof User) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
$tenants = $user->tenants()
|
||||
->where('tenants.workspace_id', (int) $workspace->getKey())
|
||||
->select('tenants.*')
|
||||
->orderBy('tenants.name')
|
||||
->get();
|
||||
|
||||
$resolver->primeMemberships($user, $tenants->modelKeys());
|
||||
|
||||
return $tenants
|
||||
->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
|
||||
->mapWithKeys(fn (Tenant $tenant): array => [
|
||||
(string) $tenant->getKey() => (string) $tenant->name,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function policyTypeOptions(): array
|
||||
{
|
||||
$tenantIds = array_map(static fn (string $tenantId): int => (int) $tenantId, array_keys($this->tenantOptions()));
|
||||
|
||||
if ($tenantIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return InventoryItem::query()
|
||||
->whereIn('tenant_id', $tenantIds)
|
||||
->whereNotNull('policy_type')
|
||||
->where('policy_type', '!=', '')
|
||||
->distinct()
|
||||
->orderBy('policy_type')
|
||||
->pluck('policy_type')
|
||||
->mapWithKeys(fn (string $policyType): array => [
|
||||
$policyType => Str::headline($policyType),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function preflightDisabledReason(): ?string
|
||||
{
|
||||
if ($this->selectionMessage !== null) {
|
||||
return $this->selectionMessage;
|
||||
}
|
||||
|
||||
if (! is_array($this->preview)) {
|
||||
return 'Select an authorized source and target tenant to generate a promotion preflight.';
|
||||
}
|
||||
|
||||
if ((int) data_get($this->preview, 'summary.total', 0) === 0) {
|
||||
return 'No governed subjects are available for this compare selection yet.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
private function normalizeTenantIdentifier(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value) && ! is_int($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string) $value);
|
||||
|
||||
return is_numeric($normalized) && (int) $normalized > 0 ? (string) (int) $normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizePolicyTypes(mixed $value): array
|
||||
{
|
||||
$allowed = array_fill_keys(array_keys($this->policyTypeOptions()), true);
|
||||
|
||||
$values = match (true) {
|
||||
is_string($value) && $value !== '' => [$value],
|
||||
is_array($value) => $value,
|
||||
default => [],
|
||||
};
|
||||
|
||||
return array_values(array_filter(array_unique(array_map(
|
||||
static fn (mixed $item): string => is_string($item) ? trim($item) : '',
|
||||
$values,
|
||||
)), static fn (string $item): bool => $item !== '' && isset($allowed[$item])));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function routeParameters(array $overrides = []): array
|
||||
{
|
||||
$parameters = [
|
||||
self::SOURCE_TENANT_QUERY_KEY => $this->sourceTenantId,
|
||||
self::TARGET_TENANT_QUERY_KEY => $this->targetTenantId,
|
||||
self::POLICY_TYPE_QUERY_KEY => $this->selectedPolicyTypes,
|
||||
];
|
||||
|
||||
if (is_array($this->navigationContextPayload)) {
|
||||
$parameters['nav'] = $this->navigationContextPayload;
|
||||
}
|
||||
|
||||
foreach ($overrides as $key => $value) {
|
||||
$parameters[$key] = $value;
|
||||
}
|
||||
|
||||
return array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
if (! is_array($this->navigationContextPayload)) {
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
return CanonicalNavigationContext::fromPayload($this->navigationContextPayload);
|
||||
}
|
||||
|
||||
private function workspace(): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
return $workspace instanceof Workspace ? $workspace : null;
|
||||
}
|
||||
}
|
||||
@ -105,26 +105,14 @@ public function mount(): void
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
$governanceContext = $this->incomingGovernanceContext();
|
||||
|
||||
if ($governanceContext?->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('return_to_governance_inbox')
|
||||
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
|
||||
->icon('heroicon-o-arrow-left')
|
||||
return [
|
||||
Action::make('clear_tenant_filter')
|
||||
->label('Clear tenant filter')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->url($governanceContext->backLinkUrl);
|
||||
}
|
||||
|
||||
$actions[] = Action::make('clear_tenant_filter')
|
||||
->label('Clear tenant filter')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||
->action(fn (): mixed => $this->clearTenantFilter());
|
||||
|
||||
return $actions;
|
||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||
->action(fn (): mixed => $this->clearTenantFilter()),
|
||||
];
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
@ -710,15 +698,6 @@ private function navigationContext(): CanonicalNavigationContext
|
||||
);
|
||||
}
|
||||
|
||||
private function incomingGovernanceContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
$context = CanonicalNavigationContext::fromRequest(request());
|
||||
|
||||
return $context?->sourceSurface === 'governance.inbox'
|
||||
? $context
|
||||
: null;
|
||||
}
|
||||
|
||||
private function queueUrl(array $overrides = []): string
|
||||
{
|
||||
$resolvedTenant = array_key_exists('tenant', $overrides)
|
||||
|
||||
@ -97,26 +97,14 @@ public function mount(): void
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
$governanceContext = $this->incomingGovernanceContext();
|
||||
|
||||
if ($governanceContext?->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('return_to_governance_inbox')
|
||||
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
|
||||
->icon('heroicon-o-arrow-left')
|
||||
return [
|
||||
Action::make('clear_tenant_filter')
|
||||
->label('Clear tenant filter')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->url($governanceContext->backLinkUrl);
|
||||
}
|
||||
|
||||
$actions[] = Action::make('clear_tenant_filter')
|
||||
->label('Clear tenant filter')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||
->action(fn (): mixed => $this->clearTenantFilter());
|
||||
|
||||
return $actions;
|
||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||
->action(fn (): mixed => $this->clearTenantFilter()),
|
||||
];
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
@ -652,15 +640,6 @@ private function navigationContext(): CanonicalNavigationContext
|
||||
);
|
||||
}
|
||||
|
||||
private function incomingGovernanceContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
$context = CanonicalNavigationContext::fromRequest(request());
|
||||
|
||||
return $context?->sourceSurface === 'governance.inbox'
|
||||
? $context
|
||||
: null;
|
||||
}
|
||||
|
||||
private function queueUrl(): string
|
||||
{
|
||||
$tenant = $this->filteredTenant();
|
||||
|
||||
@ -1,520 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Governance;
|
||||
|
||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
|
||||
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\Facades\Filament;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
class GovernanceInbox extends Page
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-inbox-stack';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $navigationLabel = 'Governance inbox';
|
||||
|
||||
protected static ?int $navigationSort = 5;
|
||||
|
||||
protected static ?string $title = 'Governance inbox';
|
||||
|
||||
protected static ?string $slug = 'governance/inbox';
|
||||
|
||||
protected string $view = 'filament.pages.governance.governance-inbox';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
*/
|
||||
private ?array $visibleFindingTenants = null;
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
*/
|
||||
private ?array $reviewTenants = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
private ?array $inboxPayload = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
private ?array $unfilteredInboxPayload = null;
|
||||
|
||||
private ?Workspace $workspace = null;
|
||||
|
||||
private ?bool $visibleAlertsFamily = null;
|
||||
|
||||
private ?bool $visibleFindingExceptionsFamily = null;
|
||||
|
||||
public ?int $tenantId = null;
|
||||
|
||||
public ?string $family = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the workspace decision surface calm and read-only.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The governance inbox routes into existing source surfaces instead of exposing row-level secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The governance inbox does not expose bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty states stay calm and capability-safe when no visible attention exists.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'The governance inbox owns no local detail surface in v1.');
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizeWorkspaceMembership();
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
$this->family = $this->resolveRequestedFamily();
|
||||
$this->ensureAtLeastOneVisibleFamily();
|
||||
$this->ensureRequestedFamilyIsVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function appliedScope(): array
|
||||
{
|
||||
$selectedTenant = $this->selectedTenant();
|
||||
$availableFamilies = collect($this->availableFamilies())
|
||||
->keyBy('key');
|
||||
|
||||
return [
|
||||
'workspace_label' => $this->workspace()?->name,
|
||||
'tenant_label' => $selectedTenant?->name,
|
||||
'tenant_prefilter_source' => $selectedTenant instanceof Tenant ? 'explicit_filter' : 'none',
|
||||
'family_key' => $this->family,
|
||||
'family_label' => $this->family !== null
|
||||
? ($availableFamilies->get($this->family)['label'] ?? Str::headline($this->family))
|
||||
: 'All attention',
|
||||
'total_count' => (int) ($this->inboxPayload()['total_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{key: string, label: string, count: int}>
|
||||
*/
|
||||
public function availableFamilies(): array
|
||||
{
|
||||
return $this->inboxPayload()['available_families'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function sections(): array
|
||||
{
|
||||
return $this->inboxPayload()['sections'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function calmEmptyState(): array
|
||||
{
|
||||
if ($this->tenantFilterAloneExcludesRows()) {
|
||||
return [
|
||||
'title' => 'This tenant filter is hiding other visible attention',
|
||||
'body' => 'The current tenant scope is calm, but other visible tenants in this workspace still have governance attention.',
|
||||
'action_label' => 'Clear tenant filter',
|
||||
'action_url' => $this->pageUrl(['tenant' => null]),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => 'No visible governance attention right now',
|
||||
'body' => 'The current workspace scope is calm across the visible governance families.',
|
||||
'action_label' => null,
|
||||
'action_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function hasTenantPrefilter(): bool
|
||||
{
|
||||
return $this->selectedTenant() instanceof Tenant;
|
||||
}
|
||||
|
||||
public function isActiveFamily(?string $familyKey): bool
|
||||
{
|
||||
return $this->family === $familyKey;
|
||||
}
|
||||
|
||||
public function pageUrl(array $overrides = []): string
|
||||
{
|
||||
$selectedTenant = $this->selectedTenant();
|
||||
$resolvedTenant = array_key_exists('tenant', $overrides)
|
||||
? $overrides['tenant']
|
||||
: ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null);
|
||||
$resolvedFamily = array_key_exists('family', $overrides)
|
||||
? $overrides['family']
|
||||
: $this->family;
|
||||
|
||||
return static::getUrl(
|
||||
panel: 'admin',
|
||||
parameters: array_filter([
|
||||
'tenant_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
|
||||
'family' => is_string($resolvedFamily) && $resolvedFamily !== '' ? $resolvedFamily : null,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
);
|
||||
}
|
||||
|
||||
public function navigationContext(): CanonicalNavigationContext
|
||||
{
|
||||
return CanonicalNavigationContext::forGovernanceInbox(
|
||||
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
|
||||
tenantId: $this->tenantId,
|
||||
backLinkUrl: $this->pageUrl(),
|
||||
familyKey: $this->family,
|
||||
);
|
||||
}
|
||||
|
||||
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 ensureAtLeastOneVisibleFamily(): void
|
||||
{
|
||||
if (
|
||||
$this->hasVisibleOperationsFamily()
|
||||
|| $this->visibleFindingTenants() !== []
|
||||
|| $this->hasVisibleFindingExceptionsFamily()
|
||||
|| $this->reviewTenants() !== []
|
||||
|| $this->hasVisibleAlertsFamily()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
abort(403);
|
||||
}
|
||||
|
||||
private function ensureRequestedFamilyIsVisible(): void
|
||||
{
|
||||
if ($this->family === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($this->family, collect($this->availableFamilies())->pluck('key')->all(), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
private function hasVisibleOperationsFamily(): bool
|
||||
{
|
||||
return $this->authorizedTenants() !== [];
|
||||
}
|
||||
|
||||
private function hasVisibleAlertsFamily(): bool
|
||||
{
|
||||
if (is_bool($this->visibleAlertsFamily)) {
|
||||
return $this->visibleAlertsFamily;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return $this->visibleAlertsFamily = false;
|
||||
}
|
||||
|
||||
return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW);
|
||||
}
|
||||
|
||||
private function hasVisibleFindingExceptionsFamily(): bool
|
||||
{
|
||||
if (is_bool($this->visibleFindingExceptionsFamily)) {
|
||||
return $this->visibleFindingExceptionsFamily;
|
||||
}
|
||||
|
||||
if ($this->authorizedTenants() === []) {
|
||||
return $this->visibleFindingExceptionsFamily = false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return $this->visibleFindingExceptionsFamily = false;
|
||||
}
|
||||
|
||||
return $this->visibleFindingExceptionsFamily = app(WorkspaceCapabilityResolver::class)
|
||||
->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
private function visibleFindingTenants(): array
|
||||
{
|
||||
if ($this->visibleFindingTenants !== null) {
|
||||
return $this->visibleFindingTenants;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$tenants = $this->authorizedTenants();
|
||||
|
||||
if (! $user instanceof User || $tenants === []) {
|
||||
return $this->visibleFindingTenants = [];
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$resolver->primeMemberships(
|
||||
$user,
|
||||
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
|
||||
);
|
||||
|
||||
return $this->visibleFindingTenants = array_values(array_filter(
|
||||
$tenants,
|
||||
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
private function reviewTenants(): array
|
||||
{
|
||||
if ($this->reviewTenants !== null) {
|
||||
return $this->reviewTenants;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return $this->reviewTenants = [];
|
||||
}
|
||||
|
||||
$service = app(TenantReviewRegisterService::class);
|
||||
|
||||
if (! $service->canAccessWorkspace($user, $workspace)) {
|
||||
return $this->reviewTenants = [];
|
||||
}
|
||||
|
||||
return $this->reviewTenants = $service->authorizedTenants($user, $workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 = $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();
|
||||
}
|
||||
|
||||
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 resolveRequestedFamily(): ?string
|
||||
{
|
||||
$family = request()->query('family');
|
||||
|
||||
if (! is_string($family)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return in_array($family, [
|
||||
'assigned_findings',
|
||||
'intake_findings',
|
||||
'finding_exceptions',
|
||||
'stale_operations',
|
||||
'alert_delivery_failures',
|
||||
'review_follow_up',
|
||||
], true) ? $family : null;
|
||||
}
|
||||
|
||||
private function workspace(): ?Workspace
|
||||
{
|
||||
if ($this->workspace instanceof Workspace) {
|
||||
return $this->workspace;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function inboxPayload(): array
|
||||
{
|
||||
if (is_array($this->inboxPayload)) {
|
||||
return $this->inboxPayload;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return $this->inboxPayload = [
|
||||
'sections' => [],
|
||||
'available_families' => [],
|
||||
'family_counts' => [],
|
||||
'total_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->inboxPayload = app(GovernanceInboxSectionBuilder::class)->build(
|
||||
user: $user,
|
||||
workspace: $workspace,
|
||||
authorizedTenants: $this->authorizedTenants(),
|
||||
visibleFindingTenants: $this->visibleFindingTenants(),
|
||||
reviewTenants: $this->reviewTenants(),
|
||||
canViewAlerts: $this->hasVisibleAlertsFamily(),
|
||||
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
|
||||
selectedTenant: $this->selectedTenant(),
|
||||
selectedFamily: $this->family,
|
||||
navigationContext: $this->navigationContext(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function unfilteredInboxPayload(): array
|
||||
{
|
||||
if (is_array($this->unfilteredInboxPayload)) {
|
||||
return $this->unfilteredInboxPayload;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return $this->unfilteredInboxPayload = [
|
||||
'sections' => [],
|
||||
'available_families' => [],
|
||||
'family_counts' => [],
|
||||
'total_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->unfilteredInboxPayload = app(GovernanceInboxSectionBuilder::class)->build(
|
||||
user: $user,
|
||||
workspace: $workspace,
|
||||
authorizedTenants: $this->authorizedTenants(),
|
||||
visibleFindingTenants: $this->visibleFindingTenants(),
|
||||
reviewTenants: $this->reviewTenants(),
|
||||
canViewAlerts: $this->hasVisibleAlertsFamily(),
|
||||
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
|
||||
selectedTenant: null,
|
||||
selectedFamily: null,
|
||||
navigationContext: $this->navigationContext(),
|
||||
);
|
||||
}
|
||||
|
||||
private function selectedTenant(): ?Tenant
|
||||
{
|
||||
if (! is_int($this->tenantId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->authorizedTenants() as $tenant) {
|
||||
if ((int) $tenant->getKey() === $this->tenantId) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function tenantFilterAloneExcludesRows(): bool
|
||||
{
|
||||
if (! is_int($this->tenantId) || $this->family !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->sections() !== []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
@ -21,7 +21,6 @@
|
||||
use App\Support\Inventory\TenantCoverageTruth;
|
||||
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -562,6 +561,6 @@ protected function coverageTruth(): ?TenantCoverageTruth
|
||||
|
||||
private function inventorySyncHistoryUrl(Tenant $tenant): string
|
||||
{
|
||||
return OperationRunLinks::index($tenant, operationType: OperationRunType::InventorySync->value);
|
||||
return OperationRunLinks::index($tenant, operationType: 'inventory_sync');
|
||||
}
|
||||
}
|
||||
|
||||
@ -208,16 +208,6 @@ protected function getHeaderActions(): array
|
||||
returnActionName: 'operate_hub_return_finding_exceptions',
|
||||
);
|
||||
|
||||
$governanceContext = $this->incomingGovernanceContext();
|
||||
|
||||
if ($governanceContext?->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('return_to_governance_inbox')
|
||||
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($governanceContext->backLinkUrl);
|
||||
}
|
||||
|
||||
$actions[] = Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
@ -489,10 +479,7 @@ public function selectedExceptionUrl(): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->appendQuery(
|
||||
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant),
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
);
|
||||
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant);
|
||||
}
|
||||
|
||||
public function selectedFindingUrl(): ?string
|
||||
@ -503,10 +490,7 @@ public function selectedFindingUrl(): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->appendQuery(
|
||||
FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
);
|
||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
||||
}
|
||||
|
||||
public function clearSelectedException(): void
|
||||
@ -670,15 +654,6 @@ private function navigationContext(): ?CanonicalNavigationContext
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
private function incomingGovernanceContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
$context = $this->navigationContext();
|
||||
|
||||
return $context?->sourceSurface === 'governance.inbox'
|
||||
? $context
|
||||
: null;
|
||||
}
|
||||
|
||||
private function normalizeSelectedFindingExceptionId(): void
|
||||
{
|
||||
if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) {
|
||||
@ -808,16 +783,4 @@ private function governanceWarningColor(FindingException $record): string
|
||||
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private function appendQuery(string $url, array $query): string
|
||||
{
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,10 +6,8 @@
|
||||
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\SupportRequest;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||
@ -18,21 +16,13 @@
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
|
||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
|
||||
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
@ -43,16 +33,10 @@
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\EmbeddedSchema;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Str;
|
||||
@ -95,11 +79,6 @@ public function getTitle(): string|Htmlable
|
||||
*/
|
||||
public ?array $navigationContextPayload = null;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $supportDiagnosticsAuditKeys = [];
|
||||
|
||||
/**
|
||||
* @return array<Action|ActionGroup>
|
||||
*/
|
||||
@ -171,14 +150,6 @@ protected function getHeaderActions(): array
|
||||
->color('gray');
|
||||
}
|
||||
|
||||
$actions[] = ActionGroup::make([
|
||||
$this->openSupportDiagnosticsAction(),
|
||||
$this->requestSupportAction(),
|
||||
])
|
||||
->label('More')
|
||||
->icon('heroicon-o-ellipsis-horizontal')
|
||||
->color('gray');
|
||||
|
||||
$actions[] = $this->resumeCaptureAction();
|
||||
|
||||
return $actions;
|
||||
@ -235,361 +206,6 @@ public function monitoringDetailSummary(): array
|
||||
];
|
||||
}
|
||||
|
||||
private function openSupportDiagnosticsAction(): Action
|
||||
{
|
||||
$action = Action::make('openSupportDiagnostics')
|
||||
->label('Open support diagnostics')
|
||||
->icon('heroicon-o-lifebuoy')
|
||||
->color('gray')
|
||||
->record($this->run)
|
||||
->modal()
|
||||
->slideOver()
|
||||
->stickyModalHeader()
|
||||
->modalHeading('Support diagnostics')
|
||||
->modalDescription('Redacted operation context from existing records.')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
|
||||
->mountUsing(function (): void {
|
||||
$this->auditOperationSupportDiagnosticsOpen();
|
||||
})
|
||||
->modalContent(fn (): View => view('filament.modals.support-diagnostic-bundle', [
|
||||
'bundle' => $this->operationRunSupportDiagnosticBundle(),
|
||||
]));
|
||||
|
||||
return UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
|
||||
->apply();
|
||||
}
|
||||
|
||||
public function authorizeOperationRunSupportRequest(): void
|
||||
{
|
||||
$this->resolveRunTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
|
||||
}
|
||||
|
||||
private function requestSupportAction(): Action
|
||||
{
|
||||
$action = Action::make('requestSupport')
|
||||
->label(__('localization.dashboard.request_support'))
|
||||
->icon('heroicon-o-paper-airplane')
|
||||
->record($this->run)
|
||||
->slideOver()
|
||||
->stickyModalHeader()
|
||||
->modalHeading(__('localization.dashboard.support_request_heading'))
|
||||
->modalDescription(__('localization.dashboard.support_request_run_description'))
|
||||
->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
|
||||
->form([
|
||||
Placeholder::make('primary_context')
|
||||
->label(__('localization.dashboard.primary_context'))
|
||||
->content(fn (): string => OperationRunLinks::identifier($this->run))
|
||||
->columnSpanFull(),
|
||||
Placeholder::make('included_context')
|
||||
->label(__('localization.dashboard.included_context'))
|
||||
->content(fn (): string => $this->operationSupportRequestAttachmentSummary())
|
||||
->columnSpanFull(),
|
||||
Placeholder::make('latest_external_handoff')
|
||||
->label(__('localization.dashboard.latest_external_handoff'))
|
||||
->content(fn (): string => $this->operationLatestSupportRequestHandoffSummary())
|
||||
->columnSpanFull(),
|
||||
Select::make('external_handoff_mode')
|
||||
->label(__('localization.dashboard.external_handoff_mode'))
|
||||
->options(fn (): array => $this->supportHandoffModeOptions())
|
||||
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
|
||||
->helperText(fn (): string => $this->supportDeskTargetAvailable()
|
||||
? __('localization.dashboard.external_handoff_mode_helper_available')
|
||||
: __('localization.dashboard.external_handoff_mode_helper_unavailable'))
|
||||
->required()
|
||||
->live()
|
||||
->native(false),
|
||||
Placeholder::make('handoff_mutation_scope')
|
||||
->label(__('localization.dashboard.handoff_mutation_scope'))
|
||||
->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode')))
|
||||
->columnSpanFull(),
|
||||
TextInput::make('external_ticket_reference')
|
||||
->label(__('localization.dashboard.external_ticket_reference'))
|
||||
->helperText(__('localization.dashboard.external_ticket_reference_helper'))
|
||||
->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
|
||||
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
|
||||
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET),
|
||||
TextInput::make('external_ticket_url')
|
||||
->label(__('localization.dashboard.external_ticket_url'))
|
||||
->helperText(__('localization.dashboard.external_ticket_url_helper'))
|
||||
->url()
|
||||
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
|
||||
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
|
||||
->columnSpanFull(),
|
||||
Select::make('severity')
|
||||
->label(__('localization.dashboard.severity'))
|
||||
->options(SupportRequest::severityOptions())
|
||||
->default(SupportRequest::SEVERITY_NORMAL)
|
||||
->required()
|
||||
->native(false),
|
||||
TextInput::make('summary')
|
||||
->label(__('localization.dashboard.summary'))
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
Textarea::make('reproduction_notes')
|
||||
->label(__('localization.dashboard.reproduction_notes'))
|
||||
->rows(4)
|
||||
->columnSpanFull(),
|
||||
TextInput::make('contact_name')
|
||||
->label(__('localization.dashboard.contact_name'))
|
||||
->default(fn (): ?string => $this->resolveViewerActor()->name),
|
||||
TextInput::make('contact_email')
|
||||
->label(__('localization.dashboard.contact_email'))
|
||||
->email()
|
||||
->default(fn (): ?string => $this->resolveViewerActor()->email),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
$actor = $this->resolveViewerActor();
|
||||
|
||||
$supportRequest = app(SupportRequestSubmissionService::class)->submitForOperationRun($this->run, $actor, $data);
|
||||
|
||||
Notification::make()
|
||||
->title(__('localization.dashboard.support_request_submitted'))
|
||||
->body($this->supportRequestNotificationBody($supportRequest))
|
||||
->when(
|
||||
$supportRequest->hasExternalHandoffFailure(),
|
||||
fn (Notification $notification): Notification => $notification->warning(),
|
||||
fn (Notification $notification): Notification => $notification->success(),
|
||||
)
|
||||
->when(
|
||||
$supportRequest->external_ticket_url !== null,
|
||||
fn (Notification $notification): Notification => $notification->actions([
|
||||
Action::make('openExternalTicket')
|
||||
->label(__('localization.dashboard.open_external_ticket'))
|
||||
->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true),
|
||||
]),
|
||||
)
|
||||
->send();
|
||||
});
|
||||
|
||||
return UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::SUPPORT_REQUESTS_CREATE)
|
||||
->apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function operationRunSupportDiagnosticBundle(): array
|
||||
{
|
||||
$user = $this->resolveViewerActor();
|
||||
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||
|
||||
return app(SupportDiagnosticBundleBuilder::class)->forOperationRun($this->run, $user);
|
||||
}
|
||||
|
||||
private function auditOperationSupportDiagnosticsOpen(): void
|
||||
{
|
||||
$user = $this->resolveViewerActor();
|
||||
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||
|
||||
$this->recordSupportDiagnosticsOpened(
|
||||
tenant: $tenant,
|
||||
bundle: $this->operationRunSupportDiagnosticBundle(),
|
||||
user: $user,
|
||||
);
|
||||
}
|
||||
|
||||
private function supportDiagnosticsTenant(): ?Tenant
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = $this->run->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
return $this->run->loadMissing('tenant')->tenant;
|
||||
}
|
||||
|
||||
private function resolveViewerActor(): User
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function resolveRunTenantForCapability(string $capability): Tenant
|
||||
{
|
||||
$tenant = $this->supportDiagnosticsTenant();
|
||||
$user = $this->resolveViewerActor();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $tenant, $capability)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function operationSupportRequestAttachmentSummary(): string
|
||||
{
|
||||
$tenant = $this->supportDiagnosticsTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return 'Only canonical redacted run context will be attached.';
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
return 'Only canonical redacted run context will be attached.';
|
||||
}
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
|
||||
? 'A redacted diagnostic snapshot and the canonical run context will be attached.'
|
||||
: 'Only the canonical redacted run context will be attached because you cannot view support diagnostics.';
|
||||
}
|
||||
|
||||
private function operationLatestSupportRequestHandoffSummary(): string
|
||||
{
|
||||
$user = $this->resolveViewerActor();
|
||||
|
||||
$summary = app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($this->run, $user);
|
||||
|
||||
return $this->formatLatestHandoffSummary($summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function supportHandoffModeOptions(): array
|
||||
{
|
||||
if (! $this->supportDeskTargetAvailable()) {
|
||||
return [
|
||||
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
|
||||
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'),
|
||||
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'),
|
||||
];
|
||||
}
|
||||
|
||||
private function supportDeskTargetAvailable(): bool
|
||||
{
|
||||
return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured();
|
||||
}
|
||||
|
||||
private function externalHandoffMutationScope(mixed $mode): string
|
||||
{
|
||||
return match ($mode) {
|
||||
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'),
|
||||
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'),
|
||||
default => __('localization.dashboard.mutation_scope_internal_only'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $summary
|
||||
*/
|
||||
private function formatLatestHandoffSummary(?array $summary): string
|
||||
{
|
||||
if ($summary === null) {
|
||||
return __('localization.dashboard.latest_external_handoff_none');
|
||||
}
|
||||
|
||||
$internalReference = (string) $summary['internal_reference'];
|
||||
|
||||
if (($summary['has_failure'] ?? false) === true) {
|
||||
return __('localization.dashboard.latest_external_handoff_failed', [
|
||||
'reference' => $internalReference,
|
||||
'failure' => (string) $summary['external_handoff_failure_summary'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (($summary['has_external_link'] ?? false) === true) {
|
||||
return __('localization.dashboard.latest_external_handoff_linked', [
|
||||
'reference' => $internalReference,
|
||||
'external' => (string) $summary['external_ticket_reference'],
|
||||
]);
|
||||
}
|
||||
|
||||
return __('localization.dashboard.latest_external_handoff_internal_only', [
|
||||
'reference' => $internalReference,
|
||||
]);
|
||||
}
|
||||
|
||||
private function supportRequestNotificationBody(SupportRequest $supportRequest): string
|
||||
{
|
||||
return match ($supportRequest->externalHandoffOutcome()) {
|
||||
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [
|
||||
'reference' => $supportRequest->internal_reference,
|
||||
'external' => $supportRequest->external_ticket_reference,
|
||||
]),
|
||||
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [
|
||||
'reference' => $supportRequest->internal_reference,
|
||||
'external' => $supportRequest->external_ticket_reference,
|
||||
]),
|
||||
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [
|
||||
'reference' => $supportRequest->internal_reference,
|
||||
'failure' => $supportRequest->external_handoff_failure_summary,
|
||||
]),
|
||||
default => __('localization.dashboard.support_request_submitted_internal_only', [
|
||||
'reference' => $supportRequest->internal_reference,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $bundle
|
||||
*/
|
||||
private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$auditKey = 'operation:'.$this->run->getKey();
|
||||
|
||||
if (in_array($auditKey, $this->supportDiagnosticsAuditKeys, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->logSupportDiagnosticsOpened(
|
||||
tenant: $tenant,
|
||||
contextType: 'operation_run',
|
||||
bundle: $bundle,
|
||||
actor: $user,
|
||||
operationRun: $this->run,
|
||||
);
|
||||
|
||||
app(ProductTelemetryRecorder::class)->record(
|
||||
eventName: ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED,
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
subjectType: 'operation_run',
|
||||
subjectId: (int) $this->run->getKey(),
|
||||
metadata: [
|
||||
'source_surface' => 'operation_run_viewer',
|
||||
'operation_type' => (string) $this->run->type,
|
||||
],
|
||||
);
|
||||
|
||||
$this->supportDiagnosticsAuditKeys[] = $auditKey;
|
||||
}
|
||||
|
||||
public function mount(OperationRun $run): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -891,14 +507,12 @@ private function canResumeCapture(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
$canonicalType = OperationCatalog::canonicalCode((string) $this->run->type);
|
||||
|
||||
if (! in_array($canonicalType, [OperationRunType::BaselineCapture->value, OperationRunType::BaselineCompare->value], true)) {
|
||||
if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||
$tokenKey = $canonicalType === OperationRunType::BaselineCapture->value
|
||||
$tokenKey = (string) $this->run->type === 'baseline_capture'
|
||||
? 'baseline_capture.resume_token'
|
||||
: 'baseline_compare.resume_token';
|
||||
$token = data_get($context, $tokenKey);
|
||||
|
||||
@ -1,695 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Reviews;
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\ReviewPackStatus;
|
||||
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\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\SurfaceCompressionContext;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
class CustomerReviewWorkspace extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
|
||||
|
||||
public const string SOURCE_SURFACE = 'customer_review_workspace';
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
||||
|
||||
protected static ?string $navigationLabel = 'Customer reviews';
|
||||
|
||||
protected static ?int $navigationSort = 44;
|
||||
|
||||
protected static ?string $title = 'Customer Review Workspace';
|
||||
|
||||
protected static ?string $slug = 'reviews/workspace';
|
||||
|
||||
protected string $view = 'filament.pages.reviews.customer-review-workspace';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
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::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->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.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the latest published review detail instead of an inline canonical detail panel.');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return __('localization.review.reporting');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('localization.review.customer_reviews');
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('localization.review.customer_review_workspace');
|
||||
}
|
||||
|
||||
public static function tenantPrefilterUrl(Tenant $tenant): string
|
||||
{
|
||||
$tenantIdentifier = filled($tenant->external_id)
|
||||
? (string) $tenant->external_id
|
||||
: (string) $tenant->getKey();
|
||||
|
||||
return static::getUrl(panel: 'admin').'?'.http_build_query([
|
||||
'tenant' => $tenantIdentifier,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
$this->mountInteractsWithTable();
|
||||
$this->auditWorkspaceOpen();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
$governanceContext = $this->incomingGovernanceContext();
|
||||
|
||||
if ($governanceContext?->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('return_to_governance_inbox')
|
||||
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($governanceContext->backLinkUrl);
|
||||
}
|
||||
|
||||
$actions[] = Action::make('clear_filters')
|
||||
->label(__('localization.review.clear_filters'))
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->hasActiveFilters())
|
||||
->action(function (): void {
|
||||
$this->clearWorkspaceFilters();
|
||||
});
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(fn (): Builder => $this->workspaceQuery())
|
||||
->defaultSort('name')
|
||||
->paginated(TablePaginationProfiles::customPage())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
||||
->columns([
|
||||
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable()->sortable(),
|
||||
TextColumn::make('latest_review')
|
||||
->label(__('localization.review.latest_review'))
|
||||
->badge()
|
||||
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
|
||||
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
|
||||
->icon(fn (Tenant $record): ?string => $this->latestReviewStateIcon($record))
|
||||
->iconColor(fn (Tenant $record): ?string => $this->latestReviewStateIconColor($record))
|
||||
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
|
||||
->wrap(),
|
||||
TextColumn::make('finding_summary')
|
||||
->label(__('localization.review.key_findings'))
|
||||
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
|
||||
->wrap(),
|
||||
TextColumn::make('accepted_risk_summary')
|
||||
->label(__('localization.review.accepted_risks'))
|
||||
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
|
||||
->wrap(),
|
||||
TextColumn::make('evidence_proof_state')
|
||||
->label(__('localization.review.evidence_proof'))
|
||||
->getStateUsing(fn (Tenant $record): string => $this->evidenceProofAvailability($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))
|
||||
->wrap(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label(__('localization.review.tenant'))
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$tenantId = $data['value'] ?? null;
|
||||
|
||||
return is_numeric($tenantId)
|
||||
? $query->whereKey((int) $tenantId)
|
||||
: $query;
|
||||
})
|
||||
->searchable(),
|
||||
])
|
||||
->actions([
|
||||
Action::make('open_latest_review')
|
||||
->label(__('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([])
|
||||
->emptyStateHeading(__('localization.review.no_entitled_tenants'))
|
||||
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
|
||||
? __('localization.review.clear_filters_description')
|
||||
: __('localization.review.adjust_filters_description'))
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters_empty')
|
||||
->label(__('localization.review.clear_filters'))
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->hasActiveFilters())
|
||||
->action(fn (): mixed => $this->clearWorkspaceFilters()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
public function authorizedTenants(): array
|
||||
{
|
||||
if ($this->authorizedTenants !== null) {
|
||||
return $this->authorizedTenants;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return $this->authorizedTenants = [];
|
||||
}
|
||||
|
||||
return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
|
||||
}
|
||||
|
||||
private function authorizePageAccess(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$service = app(TenantReviewRegisterService::class);
|
||||
|
||||
if (! $service->canAccessWorkspace($user, $workspace)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ($this->authorizedTenants() === []) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
}
|
||||
|
||||
private function 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()),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'customer_review_workspace',
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
targetLabel: __('localization.review.customer_review_workspace'),
|
||||
);
|
||||
}
|
||||
|
||||
private function workspaceQuery(): Builder
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return Tenant::query()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return app(TenantReviewRegisterService::class)->customerWorkspaceTenantQuery($user, $workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return collect($this->authorizedTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->name,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function defaultTenantFilter(): ?string
|
||||
{
|
||||
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
||||
|
||||
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
|
||||
? (string) $tenantId
|
||||
: null;
|
||||
}
|
||||
|
||||
private function applyRequestedTenantPrefilter(): void
|
||||
{
|
||||
$requestedTenant = request()->query('tenant', request()->query('tenant_id'));
|
||||
|
||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->authorizedTenants() as $tenant) {
|
||||
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
private function hasActiveFilters(): bool
|
||||
{
|
||||
return $this->currentTenantFilterId() !== null;
|
||||
}
|
||||
|
||||
private function clearWorkspaceFilters(): void
|
||||
{
|
||||
app(WorkspaceContext::class)->clearLastTenantId(request());
|
||||
$this->removeTableFilters();
|
||||
}
|
||||
|
||||
private function currentTenantFilterId(): ?int
|
||||
{
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
}
|
||||
|
||||
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
||||
}
|
||||
|
||||
private function workspace(): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
return is_numeric($workspaceId)
|
||||
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
||||
: null;
|
||||
}
|
||||
|
||||
private function latestPublishedReview(Tenant $tenant): ?TenantReview
|
||||
{
|
||||
$review = $tenant->tenantReviews->first();
|
||||
|
||||
return $review instanceof TenantReview ? $review : null;
|
||||
}
|
||||
|
||||
private function latestReviewUrl(Tenant $tenant): ?string
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->appendQuery(
|
||||
TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'),
|
||||
array_replace(
|
||||
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private function latestReviewPack(Tenant $tenant): ?ReviewPack
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
$pack = $review?->currentExportReviewPack;
|
||||
|
||||
return $pack instanceof ReviewPack ? $pack : null;
|
||||
}
|
||||
|
||||
private function latestReviewPackDownloadUrl(Tenant $tenant): ?string
|
||||
{
|
||||
$user = auth()->user();
|
||||
$pack = $this->latestReviewPack($tenant);
|
||||
|
||||
if (! $user instanceof User || ! $pack instanceof ReviewPack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
||||
'source_surface' => self::SOURCE_SURFACE,
|
||||
]);
|
||||
}
|
||||
|
||||
private function latestPublishedAt(Tenant $tenant): ?\Illuminate\Support\Carbon
|
||||
{
|
||||
return $this->latestPublishedReview($tenant)?->published_at;
|
||||
}
|
||||
|
||||
private function reviewTruth(Tenant $tenant): ?ArtifactTruthEnvelope
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
return $review instanceof TenantReview
|
||||
? app(ArtifactTruthPresenter::class)->forTenantReview($review)
|
||||
: null;
|
||||
}
|
||||
|
||||
private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
|
||||
{
|
||||
$presenter = app(ArtifactTruthPresenter::class);
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
$truth = $this->reviewTruth($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview || ! $truth instanceof ArtifactTruthEnvelope) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $presenter->compressedOutcomeFor($review, SurfaceCompressionContext::reviewRegister())
|
||||
?? $presenter->compressedOutcomeFromEnvelope($truth, SurfaceCompressionContext::reviewRegister());
|
||||
}
|
||||
|
||||
private function latestReviewStateLabel(Tenant $tenant): string
|
||||
{
|
||||
return $this->reviewOutcome($tenant)?->primaryLabel ?? __('localization.review.no_published_review');
|
||||
}
|
||||
|
||||
private function latestReviewStateColor(Tenant $tenant): string
|
||||
{
|
||||
return $this->reviewOutcome($tenant)?->primaryBadge->color ?? 'gray';
|
||||
}
|
||||
|
||||
private function latestReviewStateIcon(Tenant $tenant): ?string
|
||||
{
|
||||
return $this->reviewOutcome($tenant)?->primaryBadge->icon;
|
||||
}
|
||||
|
||||
private function latestReviewStateIconColor(Tenant $tenant): ?string
|
||||
{
|
||||
return $this->reviewOutcome($tenant)?->primaryBadge->iconColor;
|
||||
}
|
||||
|
||||
private function reviewOutcomeDescription(Tenant $tenant): ?string
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review_available');
|
||||
}
|
||||
|
||||
$primaryReason = $this->reviewOutcome($tenant)?->primaryReason;
|
||||
$summary = is_array($review->summary) ? $review->summary : [];
|
||||
$findingOutcomes = $summary['finding_outcomes'] ?? null;
|
||||
|
||||
if (! is_array($findingOutcomes)) {
|
||||
return $primaryReason;
|
||||
}
|
||||
|
||||
$findingOutcomeSummary = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
|
||||
|
||||
if ($findingOutcomeSummary === null) {
|
||||
return $primaryReason;
|
||||
}
|
||||
|
||||
return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
|
||||
}
|
||||
|
||||
private function findingSummary(Tenant $tenant): string
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review_available');
|
||||
}
|
||||
|
||||
$summary = is_array($review->summary) ? $review->summary : [];
|
||||
$findingCount = (int) ($summary['finding_count'] ?? 0);
|
||||
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
||||
$terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
|
||||
|
||||
if ($findingCount === 0) {
|
||||
return __('localization.review.no_findings_recorded');
|
||||
}
|
||||
|
||||
if ($terminalOutcomes === null) {
|
||||
return __('localization.review.findings_count_summary', ['count' => $findingCount]);
|
||||
}
|
||||
|
||||
return __('localization.review.findings_count_with_outcomes', [
|
||||
'count' => $findingCount,
|
||||
'outcomes' => $terminalOutcomes,
|
||||
]);
|
||||
}
|
||||
|
||||
private function acceptedRiskSummary(Tenant $tenant): string
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review_available');
|
||||
}
|
||||
|
||||
$summary = is_array($review->summary) ? $review->summary : [];
|
||||
$riskAcceptance = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
|
||||
$statusMarkedCount = (int) ($riskAcceptance['status_marked_count'] ?? 0);
|
||||
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
|
||||
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
|
||||
|
||||
$countSummary = match (true) {
|
||||
$statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'),
|
||||
$warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
|
||||
$validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
|
||||
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
|
||||
{
|
||||
if (! $this->latestPublishedReview($tenant) instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review_available');
|
||||
}
|
||||
|
||||
$pack = $this->latestReviewPack($tenant);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $pack instanceof ReviewPack) {
|
||||
return __('localization.review.no_current_review_pack');
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return __('localization.review.review_pack_access_unavailable');
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return __('localization.review.review_pack_unavailable');
|
||||
}
|
||||
|
||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||
return __('localization.review.review_pack_expired');
|
||||
}
|
||||
|
||||
return __('localization.review.review_pack_available');
|
||||
}
|
||||
|
||||
private function evidenceProofAvailability(Tenant $tenant): string
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review_available');
|
||||
}
|
||||
|
||||
$snapshot = $review->evidenceSnapshot;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
return __('localization.review.evidence_proof_absent');
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||
return __('localization.review.evidence_proof_access_unavailable');
|
||||
}
|
||||
|
||||
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 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
|
||||
{
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
private function incomingGovernanceContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
$context = $this->navigationContext();
|
||||
|
||||
return $context?->sourceSurface === 'governance.inbox'
|
||||
? $context
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private function appendQuery(string $url, array $query): string
|
||||
{
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,6 @@
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
@ -177,24 +176,6 @@ public function table(Table $table): Table
|
||||
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
|
||||
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
|
||||
&& in_array($record->status, ['ready', 'published'], true))
|
||||
->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false))
|
||||
->tooltip(function (TenantReview $record): ?string {
|
||||
$decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant);
|
||||
|
||||
if ((bool) ($decision['is_blocked'] ?? false)) {
|
||||
$reason = $decision['block_reason'] ?? null;
|
||||
|
||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
||||
}
|
||||
|
||||
if ((bool) ($decision['is_warning'] ?? false)) {
|
||||
$reason = $decision['warning_reason'] ?? null;
|
||||
|
||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
|
||||
])
|
||||
->bulkActions([])
|
||||
|
||||
@ -7,12 +7,7 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Support\Ai\AiPolicyMode;
|
||||
use App\Support\Ai\AiUseCaseCatalog;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
||||
use App\Services\Localization\LocaleResolver;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -25,9 +20,7 @@
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
@ -58,8 +51,6 @@ class WorkspaceSettings extends Page
|
||||
* @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}>
|
||||
*/
|
||||
private const SETTING_FIELDS = [
|
||||
'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'],
|
||||
'localization_default_locale' => ['domain' => 'localization', 'key' => 'default_locale', 'type' => 'string'],
|
||||
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
|
||||
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
|
||||
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
|
||||
@ -67,23 +58,10 @@ class WorkspaceSettings extends Page
|
||||
'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'],
|
||||
'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'],
|
||||
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
|
||||
'entitlements_plan_profile' => ['domain' => 'entitlements', 'key' => 'plan_profile', 'type' => 'string'],
|
||||
'entitlements_managed_tenant_limit_override_value' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_value', 'type' => 'int'],
|
||||
'entitlements_managed_tenant_limit_override_reason' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_reason', 'type' => 'string'],
|
||||
'entitlements_review_pack_generation_override_value' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_value', 'type' => 'bool'],
|
||||
'entitlements_review_pack_generation_override_reason' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_reason', 'type' => 'string'],
|
||||
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
|
||||
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const ENTITLEMENT_OVERRIDE_REASON_FIELDS = [
|
||||
'entitlements_managed_tenant_limit_override_value' => 'entitlements_managed_tenant_limit_override_reason',
|
||||
'entitlements_review_pack_generation_override_value' => 'entitlements_review_pack_generation_override_reason',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fields rendered as Filament KeyValue components (array state, not JSON string).
|
||||
*
|
||||
@ -133,14 +111,6 @@ class WorkspaceSettings extends Page
|
||||
*/
|
||||
public array $resolvedSettings = [];
|
||||
|
||||
/**
|
||||
* @var array{
|
||||
* plan_profile?: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
|
||||
* decisions?: array<string, array<string, mixed>>
|
||||
* }
|
||||
*/
|
||||
public array $entitlementSummary = [];
|
||||
|
||||
/**
|
||||
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
|
||||
*
|
||||
@ -155,22 +125,17 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('save')
|
||||
->label(__('localization.workspace.save'))
|
||||
->label('Save')
|
||||
->action(function (): void {
|
||||
$this->save();
|
||||
})
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||
->tooltip(fn (): ?string => $this->currentUserCanManage()
|
||||
? null
|
||||
: __('localization.workspace.no_manage_permission')),
|
||||
: 'You do not have permission to manage workspace settings.'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('localization.workspace.title');
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
@ -215,83 +180,6 @@ public function content(Schema $schema): Schema
|
||||
return $schema
|
||||
->statePath('data')
|
||||
->schema([
|
||||
Section::make(__('localization.workspace.section'))
|
||||
->description($this->sectionDescription('localization', __('localization.workspace.section_description')))
|
||||
->schema([
|
||||
Select::make('localization_default_locale')
|
||||
->label(__('localization.workspace.default_locale_label'))
|
||||
->options(LocaleResolver::localeOptions())
|
||||
->placeholder(__('localization.workspace.default_locale_placeholder'))
|
||||
->native(false)
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||
->helperText(fn (): string => $this->localeDefaultHelperText())
|
||||
->hintAction($this->makeResetAction('localization_default_locale')),
|
||||
]),
|
||||
Section::make('Workspace entitlements')
|
||||
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('entitlements_plan_profile')
|
||||
->label('Plan profile')
|
||||
->options(app(WorkspacePlanProfileCatalog::class)->optionLabels())
|
||||
->placeholder(sprintf('Use default profile (%s)', app(WorkspacePlanProfileCatalog::class)->default()['label']))
|
||||
->native(false)
|
||||
->columnSpanFull()
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||
->helperText(fn (): string => $this->planProfileFieldHelperText()),
|
||||
TextInput::make('entitlements_managed_tenant_limit_override_value')
|
||||
->label('Managed tenant activation limit override')
|
||||
->placeholder('Unset (uses plan profile default)')
|
||||
->suffix('tenants')
|
||||
->hint('0 or greater')
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(0)
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||
->helperText(fn (): string => $this->managedTenantLimitHelperText())
|
||||
->hintAction($this->makeResetAction('entitlements_managed_tenant_limit_override_value')),
|
||||
Textarea::make('entitlements_managed_tenant_limit_override_reason')
|
||||
->label('Managed tenant activation override reason')
|
||||
->rows(3)
|
||||
->maxLength(500)
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||
->helperText(fn (): string => $this->managedTenantLimitReasonHelperText()),
|
||||
Select::make('entitlements_review_pack_generation_override_value')
|
||||
->label('Review pack generation override')
|
||||
->options(self::booleanOptions())
|
||||
->placeholder('Unset (uses plan profile default)')
|
||||
->native(false)
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||
->helperText(fn (): string => $this->reviewPackGenerationHelperText())
|
||||
->hintAction($this->makeResetAction('entitlements_review_pack_generation_override_value')),
|
||||
Textarea::make('entitlements_review_pack_generation_override_reason')
|
||||
->label('Review pack generation override reason')
|
||||
->rows(3)
|
||||
->maxLength(500)
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||
->helperText(fn (): string => $this->reviewPackGenerationReasonHelperText()),
|
||||
]),
|
||||
Section::make('Workspace AI policy')
|
||||
->description($this->sectionDescription('ai', 'Control whether the workspace disables AI entirely or allows approved internal-only drafts on private-only infrastructure.'))
|
||||
->schema([
|
||||
Select::make('ai_policy_mode')
|
||||
->label('AI posture')
|
||||
->options(AiPolicyMode::optionLabels())
|
||||
->placeholder('Unset (uses default)')
|
||||
->native(false)
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||
->helperText(fn (): string => $this->aiPolicyModeHelperText())
|
||||
->hintAction($this->makeResetAction('ai_policy_mode')),
|
||||
Placeholder::make('ai_approved_use_cases')
|
||||
->label('Approved use cases')
|
||||
->content(fn (): string => $this->aiApprovedUseCasesText()),
|
||||
Placeholder::make('ai_allowed_provider_classes')
|
||||
->label('Allowed provider classes')
|
||||
->content(fn (): string => $this->aiAllowedProviderClassesText()),
|
||||
Placeholder::make('ai_blocked_data_classifications')
|
||||
->label('Blocked data classifications')
|
||||
->content(fn (): string => $this->aiBlockedDataClassificationsText()),
|
||||
]),
|
||||
Section::make('Backup settings')
|
||||
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
|
||||
->schema([
|
||||
@ -526,7 +414,7 @@ public function save(): void
|
||||
$this->loadFormState();
|
||||
|
||||
Notification::make()
|
||||
->title($changedSettingsCount > 0 ? __('localization.notifications.workspace_settings_saved') : __('localization.notifications.workspace_settings_unchanged'))
|
||||
->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
@ -545,7 +433,7 @@ public function resetSetting(string $field): void
|
||||
|
||||
if ($this->workspaceOverrideForField($field) === null) {
|
||||
Notification::make()
|
||||
->title(__('localization.notifications.setting_already_default'))
|
||||
->title('Setting already uses default')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
@ -562,57 +450,7 @@ public function resetSetting(string $field): void
|
||||
$this->loadFormState();
|
||||
|
||||
Notification::make()
|
||||
->title(__('localization.notifications.workspace_setting_reset'))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
private function resetEntitlementOverridePair(string $field): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->authorizeWorkspaceManage($user);
|
||||
|
||||
if (! $this->hasEntitlementOverridePair($field)) {
|
||||
Notification::make()
|
||||
->title('Entitlement already uses plan profile default')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$writer = app(SettingsWriter::class);
|
||||
$valueSetting = $this->settingForField($field);
|
||||
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
|
||||
$reasonSetting = $this->settingForField($reasonField);
|
||||
|
||||
if ($this->workspaceOverrideForField($field) !== null) {
|
||||
$writer->resetWorkspaceSetting(
|
||||
actor: $user,
|
||||
workspace: $this->workspace,
|
||||
domain: $valueSetting['domain'],
|
||||
key: $valueSetting['key'],
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->workspaceOverrideForField($reasonField) !== null) {
|
||||
$writer->resetWorkspaceSetting(
|
||||
actor: $user,
|
||||
workspace: $this->workspace,
|
||||
domain: $reasonSetting['domain'],
|
||||
key: $reasonSetting['key'],
|
||||
);
|
||||
}
|
||||
|
||||
$this->loadFormState();
|
||||
|
||||
Notification::make()
|
||||
->title('Workspace entitlement override reset')
|
||||
->title('Workspace setting reset to default')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
@ -652,7 +490,6 @@ private function loadFormState(): void
|
||||
$this->data = $data;
|
||||
$this->workspaceOverrides = $workspaceOverrides;
|
||||
$this->resolvedSettings = $resolvedSettings;
|
||||
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
|
||||
|
||||
$this->loadDomainLastModified();
|
||||
}
|
||||
@ -711,240 +548,37 @@ private function sectionDescription(string $domain, string $baseDescription): st
|
||||
/** @var Carbon $updatedAt */
|
||||
$updatedAt = $meta['updated_at'];
|
||||
|
||||
return __('localization.workspace.last_modified_by', [
|
||||
'description' => $baseDescription,
|
||||
'user' => $meta['user_name'],
|
||||
'time' => $updatedAt->diffForHumans(),
|
||||
]);
|
||||
return sprintf(
|
||||
'%s — Last modified by %s, %s.',
|
||||
$baseDescription,
|
||||
$meta['user_name'],
|
||||
$updatedAt->diffForHumans(),
|
||||
);
|
||||
}
|
||||
|
||||
private function makeResetAction(string $field): Action
|
||||
{
|
||||
return Action::make('reset_'.$field)
|
||||
->label(__('localization.workspace.reset'))
|
||||
->label('Reset')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(function () use ($field): void {
|
||||
if ($this->isEntitlementOverrideValueField($field)) {
|
||||
$this->resetEntitlementOverridePair($field);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resetSetting($field);
|
||||
})
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field))
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field))
|
||||
->tooltip(function () use ($field): ?string {
|
||||
if (! $this->currentUserCanManage()) {
|
||||
return __('localization.workspace.no_manage_permission');
|
||||
return 'You do not have permission to manage workspace settings.';
|
||||
}
|
||||
|
||||
if (! $this->canResetField($field)) {
|
||||
if ($this->isEntitlementOverrideValueField($field)) {
|
||||
return __('localization.workspace.no_workspace_override');
|
||||
}
|
||||
|
||||
return __('localization.workspace.no_workspace_override');
|
||||
if (! $this->hasWorkspaceOverride($field)) {
|
||||
return 'No workspace override to reset.';
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private function canResetField(string $field): bool
|
||||
{
|
||||
if ($this->isEntitlementOverrideValueField($field)) {
|
||||
return $this->hasEntitlementOverridePair($field);
|
||||
}
|
||||
|
||||
return $this->hasWorkspaceOverride($field);
|
||||
}
|
||||
|
||||
private function isEntitlementOverrideValueField(string $field): bool
|
||||
{
|
||||
return array_key_exists($field, self::ENTITLEMENT_OVERRIDE_REASON_FIELDS);
|
||||
}
|
||||
|
||||
private function hasEntitlementOverridePair(string $field): bool
|
||||
{
|
||||
if (! $this->isEntitlementOverrideValueField($field)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
|
||||
|
||||
return $this->workspaceOverrideForField($field) !== null
|
||||
|| $this->workspaceOverrideForField($reasonField) !== null;
|
||||
}
|
||||
|
||||
private function planProfileFieldHelperText(): string
|
||||
{
|
||||
$profile = $this->resolvedPlanProfile();
|
||||
$selectedProfile = $this->workspaceOverrideForField('entitlements_plan_profile');
|
||||
|
||||
if (! is_string($selectedProfile) || $selectedProfile === '') {
|
||||
return sprintf('Default profile: %s. %s', $profile['label'], $profile['description']);
|
||||
}
|
||||
|
||||
return sprintf('Effective profile: %s. %s', $profile['label'], $profile['description']);
|
||||
}
|
||||
|
||||
private function managedTenantLimitHelperText(): string
|
||||
{
|
||||
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
|
||||
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
|
||||
$currentUsage = (int) ($decision['current_usage'] ?? 0);
|
||||
$remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
|
||||
|
||||
$capacityText = $remainingCapacity < 0
|
||||
? sprintf('Over limit by %d.', abs($remainingCapacity))
|
||||
: sprintf('%d remaining.', $remainingCapacity);
|
||||
|
||||
return sprintf(
|
||||
'Effective limit: %d active managed tenants. Current usage: %d. %s Source: %s.',
|
||||
$effectiveValue,
|
||||
$currentUsage,
|
||||
$capacityText,
|
||||
$this->entitlementSourceLabel($decision),
|
||||
);
|
||||
}
|
||||
|
||||
private function managedTenantLimitReasonHelperText(): string
|
||||
{
|
||||
return $this->entitlementReasonHelperText(
|
||||
valueField: 'entitlements_managed_tenant_limit_override_value',
|
||||
key: WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
private function reviewPackGenerationHelperText(): string
|
||||
{
|
||||
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
|
||||
|
||||
return sprintf(
|
||||
'Effective state: %s. Source: %s.',
|
||||
(bool) ($decision['effective_value'] ?? false) ? 'enabled' : 'disabled',
|
||||
$this->entitlementSourceLabel($decision),
|
||||
);
|
||||
}
|
||||
|
||||
private function reviewPackGenerationReasonHelperText(): string
|
||||
{
|
||||
return $this->entitlementReasonHelperText(
|
||||
valueField: 'entitlements_review_pack_generation_override_value',
|
||||
key: WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
||||
);
|
||||
}
|
||||
|
||||
private function aiPolicyModeHelperText(): string
|
||||
{
|
||||
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
|
||||
|
||||
if (! is_array($resolved)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$mode = AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
|
||||
?? AiPolicyMode::Disabled;
|
||||
|
||||
$prefix = ! $this->hasWorkspaceOverride('ai_policy_mode')
|
||||
? sprintf('Effective posture: %s. Source: %s.', $mode->label(), $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')))
|
||||
: sprintf('Effective posture: %s.', $mode->label());
|
||||
|
||||
return sprintf('%s %s', $prefix, $mode->summary());
|
||||
}
|
||||
|
||||
private function aiApprovedUseCasesText(): string
|
||||
{
|
||||
return implode('; ', app(AiUseCaseCatalog::class)->labels()).'.';
|
||||
}
|
||||
|
||||
private function aiAllowedProviderClassesText(): string
|
||||
{
|
||||
$labels = app(AiUseCaseCatalog::class)->allowedProviderClassLabelsForMode($this->effectiveAiPolicyMode());
|
||||
|
||||
if ($labels === []) {
|
||||
return 'No provider classes are allowed while AI is disabled.';
|
||||
}
|
||||
|
||||
return implode(', ', $labels).'.';
|
||||
}
|
||||
|
||||
private function aiBlockedDataClassificationsText(): string
|
||||
{
|
||||
return implode(', ', app(AiUseCaseCatalog::class)->blockedDataClassificationLabels()).'.';
|
||||
}
|
||||
|
||||
private function effectiveAiPolicyMode(): AiPolicyMode
|
||||
{
|
||||
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
|
||||
|
||||
if (! is_array($resolved)) {
|
||||
return AiPolicyMode::Disabled;
|
||||
}
|
||||
|
||||
return AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
|
||||
?? AiPolicyMode::Disabled;
|
||||
}
|
||||
|
||||
private function entitlementReasonHelperText(string $valueField, string $key): string
|
||||
{
|
||||
$decision = $this->entitlementDecision($key);
|
||||
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
|
||||
|
||||
if ($this->workspaceOverrideForField($valueField) === null) {
|
||||
return 'Required when an explicit override value is set.';
|
||||
}
|
||||
|
||||
if ($rationale === null || $rationale === '') {
|
||||
return 'Required when an explicit override value is set.';
|
||||
}
|
||||
|
||||
return sprintf('Current rationale: %s', $rationale);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
||||
*/
|
||||
private function resolvedPlanProfile(): array
|
||||
{
|
||||
$profile = $this->entitlementSummary['plan_profile'] ?? null;
|
||||
|
||||
if (is_array($profile)) {
|
||||
return $profile;
|
||||
}
|
||||
|
||||
return app(WorkspacePlanProfileCatalog::class)->default();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function entitlementDecision(string $key): array
|
||||
{
|
||||
$decision = $this->entitlementSummary['decisions'][$key] ?? null;
|
||||
|
||||
return is_array($decision) ? $decision : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $decision
|
||||
*/
|
||||
private function entitlementSourceLabel(array $decision): string
|
||||
{
|
||||
if (($decision['source'] ?? null) === 'workspace_override') {
|
||||
return 'workspace override';
|
||||
}
|
||||
|
||||
$planProfileLabel = $decision['plan_profile_label'] ?? null;
|
||||
|
||||
if (is_string($planProfileLabel) && $planProfileLabel !== '') {
|
||||
return sprintf('%s plan profile', $planProfileLabel);
|
||||
}
|
||||
|
||||
return 'plan profile default';
|
||||
}
|
||||
|
||||
private function helperTextFor(string $field): string
|
||||
{
|
||||
$resolved = $this->resolvedSettings[$field] ?? null;
|
||||
@ -966,29 +600,6 @@ private function helperTextFor(string $field): string
|
||||
return sprintf('Effective value: %s.', $effectiveValue);
|
||||
}
|
||||
|
||||
private function localeDefaultHelperText(): string
|
||||
{
|
||||
$resolved = $this->resolvedSettings['localization_default_locale'] ?? null;
|
||||
|
||||
if (! is_array($resolved)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$effectiveLocale = LocaleResolver::normalize($resolved['value'] ?? null) ?? 'en';
|
||||
$localeLabel = LocaleResolver::localeOptions()[$effectiveLocale] ?? strtoupper($effectiveLocale);
|
||||
|
||||
if (! $this->hasWorkspaceOverride('localization_default_locale')) {
|
||||
return __('localization.workspace.default_locale_helper_unset', [
|
||||
'locale' => $localeLabel,
|
||||
'source' => $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')),
|
||||
]);
|
||||
}
|
||||
|
||||
return __('localization.workspace.default_locale_helper_set', [
|
||||
'locale' => $localeLabel,
|
||||
]);
|
||||
}
|
||||
|
||||
private function slaFieldHelperText(string $severity): string
|
||||
{
|
||||
$resolved = $this->resolvedSettings['findings_sla_days'] ?? null;
|
||||
@ -1110,27 +721,6 @@ private function normalizedInputValues(): array
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::ENTITLEMENT_OVERRIDE_REASON_FIELDS as $valueField => $reasonField) {
|
||||
if (($normalizedValues[$valueField] ?? null) === null) {
|
||||
$normalizedValues[$reasonField] = null;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($normalizedValues[$reasonField] ?? null) !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$message = match ($valueField) {
|
||||
'entitlements_managed_tenant_limit_override_value' => 'Override reason is required when a managed tenant activation limit override is set.',
|
||||
'entitlements_review_pack_generation_override_value' => 'Override reason is required when a review pack generation override is set.',
|
||||
default => 'Override reason is required when an explicit override is set.',
|
||||
};
|
||||
|
||||
$validationErrors['data.'.$reasonField] ??= [];
|
||||
$validationErrors['data.'.$reasonField][] = $message;
|
||||
}
|
||||
|
||||
return [$normalizedValues, $validationErrors];
|
||||
}
|
||||
|
||||
@ -1394,9 +984,9 @@ private function formatValueForDisplay(string $field, mixed $value): string
|
||||
private function sourceLabel(string $source): string
|
||||
{
|
||||
return match ($source) {
|
||||
'workspace_override' => __('localization.source.workspace_override'),
|
||||
'workspace_override' => 'workspace override',
|
||||
'tenant_override' => 'tenant override',
|
||||
default => __('localization.source.system_default'),
|
||||
default => 'system default',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -11,44 +11,13 @@
|
||||
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\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
|
||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
|
||||
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Widgets\Widget;
|
||||
use Filament\Widgets\WidgetConfiguration;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TenantDashboard extends Dashboard
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $supportDiagnosticsAuditKeys = [];
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('localization.dashboard.tenant_title');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $parameters
|
||||
*/
|
||||
@ -77,346 +46,4 @@ public function getColumns(): int|array
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$this->requestSupportAction(),
|
||||
$this->openSupportDiagnosticsAction(),
|
||||
];
|
||||
}
|
||||
|
||||
public function authorizeTenantSupportRequest(): void
|
||||
{
|
||||
$this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
|
||||
}
|
||||
|
||||
private function requestSupportAction(): Action
|
||||
{
|
||||
$action = Action::make('requestSupport')
|
||||
->label(__('localization.dashboard.request_support'))
|
||||
->icon('heroicon-o-paper-airplane')
|
||||
->color('gray')
|
||||
->slideOver()
|
||||
->stickyModalHeader()
|
||||
->modalHeading(__('localization.dashboard.support_request_heading'))
|
||||
->modalDescription(__('localization.dashboard.support_request_description'))
|
||||
->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
|
||||
->form([
|
||||
Placeholder::make('included_context')
|
||||
->label(__('localization.dashboard.included_context'))
|
||||
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
|
||||
->columnSpanFull(),
|
||||
Placeholder::make('latest_external_handoff')
|
||||
->label(__('localization.dashboard.latest_external_handoff'))
|
||||
->content(fn (): string => $this->tenantLatestSupportRequestHandoffSummary())
|
||||
->columnSpanFull(),
|
||||
Select::make('external_handoff_mode')
|
||||
->label(__('localization.dashboard.external_handoff_mode'))
|
||||
->options(fn (): array => $this->supportHandoffModeOptions())
|
||||
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
|
||||
->helperText(fn (): string => $this->supportDeskTargetAvailable()
|
||||
? __('localization.dashboard.external_handoff_mode_helper_available')
|
||||
: __('localization.dashboard.external_handoff_mode_helper_unavailable'))
|
||||
->required()
|
||||
->live()
|
||||
->native(false),
|
||||
Placeholder::make('handoff_mutation_scope')
|
||||
->label(__('localization.dashboard.handoff_mutation_scope'))
|
||||
->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode')))
|
||||
->columnSpanFull(),
|
||||
TextInput::make('external_ticket_reference')
|
||||
->label(__('localization.dashboard.external_ticket_reference'))
|
||||
->helperText(__('localization.dashboard.external_ticket_reference_helper'))
|
||||
->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
|
||||
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
|
||||
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET),
|
||||
TextInput::make('external_ticket_url')
|
||||
->label(__('localization.dashboard.external_ticket_url'))
|
||||
->helperText(__('localization.dashboard.external_ticket_url_helper'))
|
||||
->url()
|
||||
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
|
||||
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
|
||||
->columnSpanFull(),
|
||||
Select::make('severity')
|
||||
->label(__('localization.dashboard.severity'))
|
||||
->options(SupportRequest::severityOptions())
|
||||
->default(SupportRequest::SEVERITY_NORMAL)
|
||||
->required()
|
||||
->native(false),
|
||||
TextInput::make('summary')
|
||||
->label(__('localization.dashboard.summary'))
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
Textarea::make('reproduction_notes')
|
||||
->label(__('localization.dashboard.reproduction_notes'))
|
||||
->rows(4)
|
||||
->columnSpanFull(),
|
||||
TextInput::make('contact_name')
|
||||
->label(__('localization.dashboard.contact_name'))
|
||||
->default(fn (): ?string => $this->resolveDashboardActor()->name),
|
||||
TextInput::make('contact_email')
|
||||
->label(__('localization.dashboard.contact_email'))
|
||||
->email()
|
||||
->default(fn (): ?string => $this->resolveDashboardActor()->email),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
$actor = $this->resolveDashboardActor();
|
||||
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
|
||||
|
||||
$supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data);
|
||||
|
||||
Notification::make()
|
||||
->title(__('localization.dashboard.support_request_submitted'))
|
||||
->body($this->supportRequestNotificationBody($supportRequest))
|
||||
->when(
|
||||
$supportRequest->hasExternalHandoffFailure(),
|
||||
fn (Notification $notification): Notification => $notification->warning(),
|
||||
fn (Notification $notification): Notification => $notification->success(),
|
||||
)
|
||||
->when(
|
||||
$supportRequest->external_ticket_url !== null,
|
||||
fn (Notification $notification): Notification => $notification->actions([
|
||||
Action::make('openExternalTicket')
|
||||
->label(__('localization.dashboard.open_external_ticket'))
|
||||
->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true),
|
||||
]),
|
||||
)
|
||||
->send();
|
||||
});
|
||||
|
||||
return UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::SUPPORT_REQUESTS_CREATE)
|
||||
->apply();
|
||||
}
|
||||
|
||||
private function openSupportDiagnosticsAction(): Action
|
||||
{
|
||||
$action = Action::make('openSupportDiagnostics')
|
||||
->label(__('localization.dashboard.open_support_diagnostics'))
|
||||
->icon('heroicon-o-lifebuoy')
|
||||
->color('gray')
|
||||
->modal()
|
||||
->slideOver()
|
||||
->stickyModalHeader()
|
||||
->modalHeading(__('localization.dashboard.support_diagnostics'))
|
||||
->modalDescription(__('localization.dashboard.support_diagnostics_description'))
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(fn (Action $action): Action => $action->label(__('localization.dashboard.close')))
|
||||
->mountUsing(function (): void {
|
||||
$this->auditTenantSupportDiagnosticsOpen();
|
||||
})
|
||||
->modalContent(fn (): View => view('filament.modals.support-diagnostic-bundle', [
|
||||
'bundle' => $this->tenantSupportDiagnosticBundle(),
|
||||
]));
|
||||
|
||||
return UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
|
||||
->apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function tenantSupportDiagnosticBundle(): array
|
||||
{
|
||||
$user = $this->resolveDashboardActor();
|
||||
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||
|
||||
return app(SupportDiagnosticBundleBuilder::class)->forTenant($tenant, $user);
|
||||
}
|
||||
|
||||
private function auditTenantSupportDiagnosticsOpen(): void
|
||||
{
|
||||
$user = $this->resolveDashboardActor();
|
||||
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||
|
||||
$this->recordSupportDiagnosticsOpened(
|
||||
tenant: $tenant,
|
||||
bundle: $this->tenantSupportDiagnosticBundle(),
|
||||
user: $user,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $bundle
|
||||
*/
|
||||
private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void
|
||||
{
|
||||
$auditKey = 'tenant:'.$tenant->getKey();
|
||||
|
||||
if (in_array($auditKey, $this->supportDiagnosticsAuditKeys, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->logSupportDiagnosticsOpened(
|
||||
tenant: $tenant,
|
||||
contextType: 'tenant',
|
||||
bundle: $bundle,
|
||||
actor: $user,
|
||||
);
|
||||
|
||||
app(ProductTelemetryRecorder::class)->record(
|
||||
eventName: ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED,
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
subjectType: 'tenant',
|
||||
subjectId: (int) $tenant->getKey(),
|
||||
metadata: [
|
||||
'source_surface' => 'tenant_dashboard',
|
||||
],
|
||||
);
|
||||
|
||||
$this->supportDiagnosticsAuditKeys[] = $auditKey;
|
||||
}
|
||||
|
||||
private function resolveDashboardActor(): User
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function resolveCurrentTenantForCapability(string $capability): Tenant
|
||||
{
|
||||
$user = $this->resolveDashboardActor();
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $tenant, $capability)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function tenantSupportRequestAttachmentSummary(): string
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return 'Only canonical redacted tenant context will be attached.';
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
return 'Only canonical redacted tenant context will be attached.';
|
||||
}
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
|
||||
? 'A redacted diagnostic snapshot and the canonical tenant context will be attached.'
|
||||
: 'Only the canonical redacted tenant context will be attached because you cannot view support diagnostics.';
|
||||
}
|
||||
|
||||
private function tenantLatestSupportRequestHandoffSummary(): string
|
||||
{
|
||||
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
|
||||
$user = $this->resolveDashboardActor();
|
||||
|
||||
$summary = app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user);
|
||||
|
||||
return $this->formatLatestHandoffSummary($summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function supportHandoffModeOptions(): array
|
||||
{
|
||||
if (! $this->supportDeskTargetAvailable()) {
|
||||
return [
|
||||
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
|
||||
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'),
|
||||
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'),
|
||||
];
|
||||
}
|
||||
|
||||
private function supportDeskTargetAvailable(): bool
|
||||
{
|
||||
return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured();
|
||||
}
|
||||
|
||||
private function externalHandoffMutationScope(mixed $mode): string
|
||||
{
|
||||
return match ($mode) {
|
||||
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'),
|
||||
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'),
|
||||
default => __('localization.dashboard.mutation_scope_internal_only'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $summary
|
||||
*/
|
||||
private function formatLatestHandoffSummary(?array $summary): string
|
||||
{
|
||||
if ($summary === null) {
|
||||
return __('localization.dashboard.latest_external_handoff_none');
|
||||
}
|
||||
|
||||
$internalReference = (string) $summary['internal_reference'];
|
||||
|
||||
if (($summary['has_failure'] ?? false) === true) {
|
||||
return __('localization.dashboard.latest_external_handoff_failed', [
|
||||
'reference' => $internalReference,
|
||||
'failure' => (string) $summary['external_handoff_failure_summary'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (($summary['has_external_link'] ?? false) === true) {
|
||||
return __('localization.dashboard.latest_external_handoff_linked', [
|
||||
'reference' => $internalReference,
|
||||
'external' => (string) $summary['external_ticket_reference'],
|
||||
]);
|
||||
}
|
||||
|
||||
return __('localization.dashboard.latest_external_handoff_internal_only', [
|
||||
'reference' => $internalReference,
|
||||
]);
|
||||
}
|
||||
|
||||
private function supportRequestNotificationBody(SupportRequest $supportRequest): string
|
||||
{
|
||||
return match ($supportRequest->externalHandoffOutcome()) {
|
||||
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [
|
||||
'reference' => $supportRequest->internal_reference,
|
||||
'external' => $supportRequest->external_ticket_reference,
|
||||
]),
|
||||
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [
|
||||
'reference' => $supportRequest->internal_reference,
|
||||
'external' => $supportRequest->external_ticket_reference,
|
||||
]),
|
||||
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [
|
||||
'reference' => $supportRequest->internal_reference,
|
||||
'failure' => $supportRequest->external_handoff_failure_summary,
|
||||
]),
|
||||
default => __('localization.dashboard.support_request_submitted_internal_only', [
|
||||
'reference' => $supportRequest->internal_reference,
|
||||
]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -25,7 +25,6 @@
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -458,7 +457,7 @@ public static function table(Table $table): Table
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BackupScheduleExecute->value,
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
@ -529,7 +528,7 @@ public static function table(Table $table): Table
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BackupScheduleExecute->value,
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
@ -756,7 +755,7 @@ public static function table(Table $table): Table
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BackupScheduleExecute->value,
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
@ -853,7 +852,7 @@ public static function table(Table $table): Table
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BackupScheduleExecute->value,
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
|
||||
@ -32,8 +32,6 @@
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
@ -875,7 +873,7 @@ private static function latestBaselineCaptureEnvelope(BaselineProfile $profile):
|
||||
{
|
||||
$run = OperationRun::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCapture->value))
|
||||
->where('type', 'baseline_capture')
|
||||
->where('context->baseline_profile_id', (int) $profile->getKey())
|
||||
->where('status', 'completed')
|
||||
->orderByDesc('completed_at')
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
@ -341,8 +340,8 @@ private function compareAssignedTenantsAction(): Action
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
$toast = (int) $result['queuedCount'] > 0
|
||||
? OperationUxPresenter::queuedToast(OperationRunType::BaselineCompare->value)
|
||||
: OperationUxPresenter::alreadyQueuedToast(OperationRunType::BaselineCompare->value);
|
||||
? OperationUxPresenter::queuedToast('baseline_compare')
|
||||
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
|
||||
|
||||
$toast
|
||||
->body($summary.' Open Operations for progress and next steps.')
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
@ -174,12 +173,9 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null)
|
||||
->openUrlInNewTab()
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
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()),
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
||||
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Summary')
|
||||
@ -225,7 +221,6 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('Raw summary JSON')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(4),
|
||||
@ -240,7 +235,7 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
if (! static::isCustomerWorkspaceFlow() && is_numeric($record->operation_run_id)) {
|
||||
if (is_numeric($record->operation_run_id)) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'operation_run',
|
||||
label: 'Operation',
|
||||
@ -259,20 +254,12 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||
->first();
|
||||
|
||||
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, [
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
]);
|
||||
}
|
||||
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'review_pack',
|
||||
label: 'Review pack',
|
||||
value: sprintf('#%d', (int) $pack->getKey()),
|
||||
secondaryValue: 'Inspect the latest executive-pack output for this evidence basis.',
|
||||
targetUrl: $packUrl,
|
||||
targetUrl: ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant),
|
||||
targetKind: 'direct_record',
|
||||
priority: 20,
|
||||
actionLabel: 'View review pack',
|
||||
@ -280,40 +267,9 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||
)->toArray();
|
||||
}
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'customer_review_workspace',
|
||||
label: 'Customer workspace',
|
||||
value: $record->tenant->name,
|
||||
secondaryValue: 'Open the customer-safe review workspace prefiltered to this tenant.',
|
||||
targetUrl: CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
|
||||
targetKind: 'canonical_page',
|
||||
priority: 30,
|
||||
actionLabel: 'Open customer workspace',
|
||||
contextBadge: 'Reporting',
|
||||
)->toArray();
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public static function isCustomerWorkspaceFlow(): bool
|
||||
{
|
||||
return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
return $table
|
||||
|
||||
@ -5,13 +5,8 @@
|
||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||
|
||||
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\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
@ -25,13 +20,6 @@ class ViewEvidenceSnapshot extends ViewRecord
|
||||
{
|
||||
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
|
||||
{
|
||||
return EvidenceSnapshotResource::resolveScopedRecordOrFail($key);
|
||||
@ -39,10 +27,6 @@ protected function resolveRecord(int|string $key): Model
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
if (EvidenceSnapshotResource::isCustomerWorkspaceFlow()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$refreshRule = GovernanceActionCatalog::rule('refresh_evidence');
|
||||
$expireRule = GovernanceActionCatalog::rule('expire_snapshot');
|
||||
|
||||
@ -106,41 +90,4 @@ protected function getHeaderActions(): array
|
||||
->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,
|
||||
],
|
||||
],
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,6 +75,8 @@ class FindingResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $navigationLabel = 'Findings';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
@ -84,26 +86,6 @@ public static function shouldRegisterNavigation(): bool
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('localization.navigation.findings');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return __('localization.navigation.governance');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('localization.navigation.findings');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('localization.navigation.findings');
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -308,6 +290,8 @@ public static function infolist(Schema $schema): Schema
|
||||
? OperationRunLinks::tenantlessView((int) $record->current_operation_run_id, static::findingRunNavigationContext($record))
|
||||
: null)
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
|
||||
TextEntry::make('first_seen_at')->label('First seen')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('times_seen')->label('Times seen')->placeholder('—'),
|
||||
@ -998,6 +982,7 @@ public static function table(Table $table): Table
|
||||
if (! in_array((string) $record->status, [
|
||||
Finding::STATUS_NEW,
|
||||
Finding::STATUS_REOPENED,
|
||||
Finding::STATUS_ACKNOWLEDGED,
|
||||
], true)) {
|
||||
$skippedCount++;
|
||||
|
||||
@ -1413,6 +1398,7 @@ public static function triageAction(): Actions\Action
|
||||
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||
Finding::STATUS_NEW,
|
||||
Finding::STATUS_REOPENED,
|
||||
Finding::STATUS_ACKNOWLEDGED,
|
||||
], true))
|
||||
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||
static::runWorkflowMutation(
|
||||
@ -1437,6 +1423,7 @@ public static function startProgressAction(): Actions\Action
|
||||
->color('info')
|
||||
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||
Finding::STATUS_TRIAGED,
|
||||
Finding::STATUS_ACKNOWLEDGED,
|
||||
], true))
|
||||
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||
static::runWorkflowMutation(
|
||||
|
||||
@ -6,12 +6,17 @@
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
||||
use App\Filament\Widgets\Tenant\FindingStatsOverview;
|
||||
use App\Jobs\BackfillFindingLifecycleJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Actions;
|
||||
@ -71,15 +76,15 @@ public function getTabs(): array
|
||||
$stats = FindingResource::findingStatsForCurrentTenant();
|
||||
|
||||
return [
|
||||
'all' => Tab::make(__('localization.findings.all'))
|
||||
'all' => Tab::make('All')
|
||||
->icon('heroicon-m-list-bullet'),
|
||||
'needs_action' => Tab::make(__('localization.findings.needs_action'))
|
||||
'needs_action' => Tab::make('Needs action')
|
||||
->icon('heroicon-m-exclamation-triangle')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->whereIn('status', Finding::openStatusesForQuery()))
|
||||
->badge($stats['open'] > 0 ? $stats['open'] : null)
|
||||
->badgeColor('warning'),
|
||||
'overdue' => Tab::make(__('localization.findings.overdue'))
|
||||
'overdue' => Tab::make('Overdue')
|
||||
->icon('heroicon-m-clock')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
@ -87,11 +92,11 @@ public function getTabs(): array
|
||||
->where('due_at', '<', now()))
|
||||
->badge($stats['overdue'] > 0 ? $stats['overdue'] : null)
|
||||
->badgeColor('danger'),
|
||||
'risk_accepted' => Tab::make(__('localization.findings.risk_accepted'))
|
||||
'risk_accepted' => Tab::make('Risk accepted')
|
||||
->icon('heroicon-m-shield-check')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->where('status', Finding::STATUS_RISK_ACCEPTED)),
|
||||
'resolved' => Tab::make(__('localization.findings.resolved'))
|
||||
'resolved' => Tab::make('Resolved')
|
||||
->icon('heroicon-m-archive-box')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])),
|
||||
@ -102,6 +107,84 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
if ((bool) config('tenantpilot.allow_admin_maintenance_actions', false)) {
|
||||
$actions[] = UiEnforcement::forAction(
|
||||
Actions\Action::make('backfill_lifecycle')
|
||||
->label('Backfill findings lifecycle')
|
||||
->icon('heroicon-o-wrench-screwdriver')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Backfill findings lifecycle')
|
||||
->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.')
|
||||
->action(function (OperationRunService $operationRuns): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$opRun = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'findings.lifecycle.backfill',
|
||||
identityInputs: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'trigger' => 'backfill',
|
||||
],
|
||||
context: [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiator_user_id' => (int) $user->getKey(),
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$runUrl = OperationRunLinks::view($opRun, $tenant);
|
||||
|
||||
if ($opRun->wasRecentlyCreated === false) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRuns->dispatchOrFail($opRun, function () use ($tenant, $user): void {
|
||||
BackfillFindingLifecycleJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
);
|
||||
});
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->body('The backfill will run in the background. You can continue working while it completes.')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply();
|
||||
}
|
||||
|
||||
$actions[] = UiEnforcement::forAction(
|
||||
Actions\Action::make('triage_all_matching')
|
||||
->label('Triage all matching')
|
||||
@ -171,6 +254,7 @@ protected function getHeaderActions(): array
|
||||
if (! in_array((string) $finding->status, [
|
||||
Finding::STATUS_NEW,
|
||||
Finding::STATUS_REOPENED,
|
||||
Finding::STATUS_ACKNOWLEDGED,
|
||||
], true)) {
|
||||
$skippedCount++;
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@ protected function getHeaderActions(): array
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
|
||||
->color('gray'),
|
||||
Actions\Action::make('open_approval_queue')
|
||||
->label(__('localization.findings.open_approval_queue'))
|
||||
->label('Open approval queue')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(function (): bool {
|
||||
@ -61,7 +61,7 @@ protected function getHeaderActions(): array
|
||||
: null;
|
||||
}),
|
||||
Actions\ActionGroup::make(FindingResource::workflowActions())
|
||||
->label(__('localization.findings.actions'))
|
||||
->label('Actions')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
]);
|
||||
|
||||
@ -15,7 +15,6 @@
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -176,7 +175,7 @@ protected function getHeaderActions(): array
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::InventorySync->value,
|
||||
type: 'inventory_sync',
|
||||
identityInputs: [
|
||||
'selection_hash' => $computed['selection_hash'],
|
||||
],
|
||||
|
||||
@ -27,7 +27,6 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\RunDurationInsights;
|
||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||
@ -231,9 +230,7 @@ public static function table(Table $table): Table
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->whereIn('type', OperationCatalog::rawValuesForCanonical(
|
||||
OperationCatalog::canonicalCode($value),
|
||||
));
|
||||
return $query->whereIn('type', OperationCatalog::rawValuesForCanonical($value));
|
||||
}),
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
|
||||
@ -414,7 +411,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
);
|
||||
}
|
||||
|
||||
if (OperationCatalog::canonicalCode((string) $record->type) === OperationRunType::BaselineCompare->value) {
|
||||
if ((string) $record->type === 'baseline_compare') {
|
||||
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
||||
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
||||
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||
@ -469,7 +466,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
}
|
||||
}
|
||||
|
||||
if (OperationCatalog::canonicalCode((string) $record->type) === OperationRunType::BaselineCapture->value) {
|
||||
if ((string) $record->type === 'baseline_capture') {
|
||||
$baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
|
||||
|
||||
if ($baselineCaptureEvidence !== []) {
|
||||
@ -1449,7 +1446,7 @@ private static function reconciliationPayload(OperationRun $record): array
|
||||
*/
|
||||
private static function inventorySyncCoverageSection(OperationRun $record): ?array
|
||||
{
|
||||
if (OperationCatalog::canonicalCode((string) $record->type) !== OperationRunType::InventorySync->value) {
|
||||
if ((string) $record->type !== 'inventory_sync') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -20,15 +20,12 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
@ -53,7 +50,6 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use UnitEnum;
|
||||
|
||||
class ProviderConnectionResource extends Resource
|
||||
@ -488,62 +484,6 @@ private static function verificationStatusLabelFromState(mixed $state): string
|
||||
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label;
|
||||
}
|
||||
|
||||
private static function targetScopeHelpText(): string
|
||||
{
|
||||
return 'The platform scope this provider connection represents. For Microsoft, use the tenant directory ID for that scope.';
|
||||
}
|
||||
|
||||
private static function targetScopeSummary(?ProviderConnection $record): string
|
||||
{
|
||||
if (! $record instanceof ProviderConnection) {
|
||||
return 'Target scope is set when this connection is saved.';
|
||||
}
|
||||
|
||||
try {
|
||||
return ProviderConnectionSurfaceSummary::forConnection($record)->targetScopeSummary();
|
||||
} catch (InvalidArgumentException) {
|
||||
return 'Target scope needs review';
|
||||
}
|
||||
}
|
||||
|
||||
private static function providerIdentityContext(?ProviderConnection $record): ?string
|
||||
{
|
||||
if (! $record instanceof ProviderConnection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return ProviderConnectionSurfaceSummary::forConnection($record)->contextualIdentityLine();
|
||||
} catch (InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function targetScopeAuditMetadata(ProviderConnection $record, array $extra = []): array
|
||||
{
|
||||
try {
|
||||
return app(ProviderConnectionTargetScopeNormalizer::class)->auditMetadataForConnection($record, $extra);
|
||||
} catch (InvalidArgumentException) {
|
||||
return array_merge([
|
||||
'provider_connection_id' => (int) $record->getKey(),
|
||||
'provider' => (string) $record->provider,
|
||||
'target_scope' => [
|
||||
'provider' => (string) $record->provider,
|
||||
'scope_kind' => 'tenant',
|
||||
'scope_identifier' => (string) $record->entra_tenant_id,
|
||||
'scope_display_name' => (string) ($record->tenant?->name ?? $record->display_name ?? $record->entra_tenant_id),
|
||||
'shared_label' => 'Target scope',
|
||||
'shared_help_text' => static::targetScopeHelpText(),
|
||||
],
|
||||
'provider_identity_context' => [],
|
||||
], $extra);
|
||||
}
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
@ -556,17 +496,11 @@ public static function form(Schema $schema): Schema
|
||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||
->maxLength(255),
|
||||
TextInput::make('entra_tenant_id')
|
||||
->label('Target scope ID')
|
||||
->label('Entra tenant ID')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText(static::targetScopeHelpText())
|
||||
->validationAttribute('target scope ID')
|
||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||
->rules(['uuid']),
|
||||
Placeholder::make('target_scope_display')
|
||||
->label('Target scope')
|
||||
->content(fn (?ProviderConnection $record): string => static::targetScopeSummary($record))
|
||||
->visible(fn (?ProviderConnection $record): bool => $record instanceof ProviderConnection),
|
||||
Placeholder::make('connection_type_display')
|
||||
->label('Connection type')
|
||||
->content(fn (?ProviderConnection $record): string => static::providerConnectionTypeLabel($record)),
|
||||
@ -629,9 +563,8 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('Display name'),
|
||||
Infolists\Components\TextEntry::make('provider')
|
||||
->label('Provider'),
|
||||
Infolists\Components\TextEntry::make('target_scope')
|
||||
->label('Target scope')
|
||||
->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record))
|
||||
Infolists\Components\TextEntry::make('entra_tenant_id')
|
||||
->label('Entra tenant ID')
|
||||
->copyable(),
|
||||
Infolists\Components\TextEntry::make('connection_type')
|
||||
->label('Connection type')
|
||||
@ -681,11 +614,6 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('Migration review')
|
||||
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
||||
Infolists\Components\TextEntry::make('provider_identity_context')
|
||||
->label('Provider identity details')
|
||||
->state(fn (ProviderConnection $record): ?string => static::providerIdentityContext($record))
|
||||
->placeholder('n/a')
|
||||
->columnSpanFull(),
|
||||
Infolists\Components\TextEntry::make('last_error_reason_code')
|
||||
->label('Last error reason')
|
||||
->placeholder('n/a'),
|
||||
@ -743,15 +671,9 @@ public static function table(Table $table): Table
|
||||
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable()->sortable(),
|
||||
Tables\Columns\TextColumn::make('provider')
|
||||
->label('Provider')
|
||||
->formatStateUsing(fn (?string $state): string => Str::headline((string) $state)),
|
||||
Tables\Columns\TextColumn::make('target_scope')
|
||||
->label('Target scope')
|
||||
->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record))
|
||||
->copyable(),
|
||||
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Microsoft tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean()->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
|
||||
Tables\Columns\TextColumn::make('connection_type')
|
||||
->label('Connection type')
|
||||
->badge()
|
||||
@ -950,7 +872,7 @@ public static function makeInventorySyncAction(): Actions\Action
|
||||
static::handleProviderOperationAction(
|
||||
record: $record,
|
||||
gate: $gate,
|
||||
operationType: OperationRunType::InventorySync->value,
|
||||
operationType: 'inventory_sync',
|
||||
blockedTitle: 'Inventory sync blocked',
|
||||
dispatcher: function (Tenant $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void {
|
||||
ProviderInventorySyncJob::dispatch(
|
||||
@ -1027,7 +949,10 @@ public static function makeSetDefaultAction(): Actions\Action
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.default_set',
|
||||
context: [
|
||||
'metadata' => static::targetScopeAuditMetadata($record),
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
@ -1089,12 +1014,15 @@ public static function makeEnableDedicatedOverrideAction(string $source, ?string
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $record->getKey(),
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'to_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'client_id' => (string) $data['client_id'],
|
||||
'source' => $source,
|
||||
]),
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
@ -1233,11 +1161,14 @@ public static function makeRevertToPlatformAction(string $source, ?string $modal
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $record->getKey(),
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'to_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'source' => $source,
|
||||
]),
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
@ -1302,12 +1233,14 @@ public static function makeEnableConnectionAction(): Actions\Action
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.enabled',
|
||||
context: [
|
||||
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
|
||||
'to_lifecycle' => 'enabled',
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'credentials_present' => $hadCredentials,
|
||||
]),
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
@ -1369,10 +1302,12 @@ public static function makeDisableConnectionAction(): Actions\Action
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.disabled',
|
||||
context: [
|
||||
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
|
||||
'to_lifecycle' => 'disabled',
|
||||
]),
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
|
||||
@ -9,12 +9,9 @@
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CreateProviderConnection extends CreateRecord
|
||||
{
|
||||
@ -31,21 +28,6 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
||||
}
|
||||
|
||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
|
||||
provider: 'microsoft',
|
||||
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||
scopeIdentifier: (string) ($data['entra_tenant_id'] ?? ''),
|
||||
scopeDisplayName: (string) ($data['display_name'] ?? ''),
|
||||
providerSpecificIdentity: [
|
||||
'microsoft_tenant_id' => (string) ($data['entra_tenant_id'] ?? ''),
|
||||
],
|
||||
);
|
||||
|
||||
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||
throw ValidationException::withMessages([
|
||||
'entra_tenant_id' => $targetScope['message'],
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
@ -88,9 +70,11 @@ protected function afterCreate(): void
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.created',
|
||||
context: [
|
||||
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'connection_type' => $record->connection_type->value,
|
||||
]),
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
|
||||
@ -19,8 +19,6 @@
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\Action;
|
||||
@ -28,7 +26,6 @@
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EditProviderConnection extends EditRecord
|
||||
{
|
||||
@ -80,22 +77,6 @@ protected function mutateFormDataBeforeSave(array $data): array
|
||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||
unset($data['is_default']);
|
||||
|
||||
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
|
||||
provider: 'microsoft',
|
||||
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||
scopeIdentifier: (string) ($data['entra_tenant_id'] ?? ''),
|
||||
scopeDisplayName: (string) ($data['display_name'] ?? ''),
|
||||
providerSpecificIdentity: [
|
||||
'microsoft_tenant_id' => (string) ($data['entra_tenant_id'] ?? ''),
|
||||
],
|
||||
);
|
||||
|
||||
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||
throw ValidationException::withMessages([
|
||||
'entra_tenant_id' => $targetScope['message'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@ -138,9 +119,11 @@ protected function afterSave(): void
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.updated',
|
||||
context: [
|
||||
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [
|
||||
'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($changedFields),
|
||||
]),
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'fields' => $changedFields,
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
@ -156,7 +139,10 @@ protected function afterSave(): void
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.default_set',
|
||||
context: [
|
||||
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record),
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
|
||||
@ -237,12 +237,12 @@ private function resolveTenantForCreateAction(): ?Tenant
|
||||
|
||||
public function getTableEmptyStateHeading(): ?string
|
||||
{
|
||||
return 'No provider connections found';
|
||||
return 'No Microsoft connections found';
|
||||
}
|
||||
|
||||
public function getTableEmptyStateDescription(): ?string
|
||||
{
|
||||
return 'Start with a platform-managed provider connection. Dedicated overrides are handled separately with stronger authorization.';
|
||||
return 'Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.';
|
||||
}
|
||||
|
||||
public function getTableEmptyStateActions(): array
|
||||
|
||||
@ -18,9 +18,7 @@
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Rules\SkipOrUuidRule;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
@ -33,18 +31,14 @@
|
||||
use App\Services\Providers\ProviderOperationStartResult;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupQuality\BackupQualityResolver;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use App\Support\OperationalControls\OperationalControlEvaluator;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\RestoreRunIdempotency;
|
||||
use App\Support\RestoreRunStatus;
|
||||
@ -1927,26 +1921,16 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
->executionSafetySnapshot($tenant, $user, $data)
|
||||
->toArray();
|
||||
|
||||
try {
|
||||
[$result, $restoreRun] = static::startQueuedRestoreExecution(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: $selectedItemIds,
|
||||
preview: $preview,
|
||||
metadata: $metadata,
|
||||
groupMapping: $groupMapping,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
);
|
||||
} catch (OperationalControlBlockedException $exception) {
|
||||
Notification::make()
|
||||
->title($exception->title())
|
||||
->body($exception->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
throw new \Filament\Support\Exceptions\Halt;
|
||||
}
|
||||
[$result, $restoreRun] = static::startQueuedRestoreExecution(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: $selectedItemIds,
|
||||
preview: $preview,
|
||||
metadata: $metadata,
|
||||
groupMapping: $groupMapping,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
);
|
||||
|
||||
app(ProviderOperationStartResultPresenter::class)
|
||||
->notification(
|
||||
@ -1994,13 +1978,6 @@ private static function startQueuedRestoreExecution(
|
||||
$initiator = auth()->user();
|
||||
$initiator = $initiator instanceof User ? $initiator : null;
|
||||
|
||||
static::guardRestoreExecutionOperationalControl(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: $selectedItemIds,
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
$queuedRestoreRun = null;
|
||||
|
||||
$dispatcher = function (OperationRun $run) use (
|
||||
@ -2120,58 +2097,6 @@ private static function startQueuedRestoreExecution(
|
||||
return [$result, $queuedRestoreRun?->refresh()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $selectedItemIds
|
||||
*/
|
||||
private static function guardRestoreExecutionOperationalControl(
|
||||
Tenant $tenant,
|
||||
BackupSet $backupSet,
|
||||
?array $selectedItemIds,
|
||||
?User $initiator,
|
||||
): void {
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw new \RuntimeException('Restore execution requires a workspace context.');
|
||||
}
|
||||
|
||||
$decision = app(OperationalControlEvaluator::class)->evaluate('restore.execute', $workspace);
|
||||
|
||||
if (! $decision->isPaused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::OperationalControlExecutionBlocked,
|
||||
context: [
|
||||
'metadata' => array_filter([
|
||||
'control_key' => $decision->controlKey,
|
||||
'scope_type' => $decision->matchedScopeType,
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'reason_text' => $decision->reasonText,
|
||||
'expires_at' => $decision->expiresAt?->toIso8601String(),
|
||||
'actor_id' => $initiator?->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'selected_item_count' => is_array($selectedItemIds) ? count($selectedItemIds) : null,
|
||||
'requested_scope' => 'restore.execute',
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
],
|
||||
actor: $initiator,
|
||||
status: 'blocked',
|
||||
resourceType: 'operational_control',
|
||||
resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null,
|
||||
targetLabel: OperationCatalog::label('restore.execute'),
|
||||
summary: 'Restore execution blocked by operational control',
|
||||
tenant: $tenant,
|
||||
);
|
||||
|
||||
throw OperationalControlBlockedException::forDecision(
|
||||
decision: $decision,
|
||||
actionLabel: OperationCatalog::label('restore.execute'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $selectedItemIds
|
||||
*/
|
||||
@ -2604,26 +2529,16 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
|
||||
$metadata['rerun_of_restore_run_id'] = $record->id;
|
||||
|
||||
try {
|
||||
[$result, $newRun] = static::startQueuedRestoreExecution(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: $selectedItemIds,
|
||||
preview: $preview,
|
||||
metadata: $metadata,
|
||||
groupMapping: $groupMapping,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
);
|
||||
} catch (OperationalControlBlockedException $exception) {
|
||||
Notification::make()
|
||||
->title($exception->title())
|
||||
->body($exception->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
[$result, $newRun] = static::startQueuedRestoreExecution(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: $selectedItemIds,
|
||||
preview: $preview,
|
||||
metadata: $metadata,
|
||||
groupMapping: $groupMapping,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
);
|
||||
|
||||
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
@ -2,10 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages;
|
||||
use App\Models\ReviewPack;
|
||||
@ -13,7 +10,6 @@
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
@ -49,8 +45,6 @@
|
||||
|
||||
class ReviewPackResource extends Resource
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = ReviewPack::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
@ -108,9 +102,9 @@ public static function canView(Model $record): bool
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action appears in the list header once review packs exist.')
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state carries the single Generate CTA while the registry is empty.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download remains the only direct row shortcut and Expire is grouped under More.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
||||
@ -148,8 +142,7 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('file_size')
|
||||
->label('File size')
|
||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—'),
|
||||
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
@ -185,7 +178,6 @@ public static function infolist(Schema $schema): Schema
|
||||
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
|
||||
])
|
||||
->columns(2)
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Metadata')
|
||||
@ -198,13 +190,6 @@ public static function infolist(Schema $schema): Schema
|
||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant)
|
||||
: null)
|
||||
->placeholder('—'),
|
||||
TextEntry::make('customer_workspace')
|
||||
->label('Customer workspace')
|
||||
->state(fn (): string => 'Open workspace')
|
||||
->url(fn (ReviewPack $record): ?string => $record->tenant instanceof Tenant
|
||||
? CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant)
|
||||
: null)
|
||||
->placeholder('—'),
|
||||
TextEntry::make('summary.review_status')
|
||||
->label('Review status')
|
||||
->badge()
|
||||
@ -229,12 +214,9 @@ public static function infolist(Schema $schema): Schema
|
||||
return OperationRunLinks::tenantlessView((int) $record->operation_run_id);
|
||||
})
|
||||
->openUrlInNewTab()
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||
->placeholder('—'),
|
||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
|
||||
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—'),
|
||||
TextEntry::make('created_at')->label('Created')->dateTime(),
|
||||
])
|
||||
->columns(2)
|
||||
@ -248,7 +230,9 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('evidenceSnapshot.id')
|
||||
->label('Snapshot')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (ReviewPack $record): ?string => static::evidenceSnapshotUrl($record)),
|
||||
->url(fn (ReviewPack $record): ?string => $record->evidenceSnapshot
|
||||
? TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||
: null),
|
||||
TextEntry::make('evidenceSnapshot.completeness_state')
|
||||
->label('Snapshot completeness')
|
||||
->badge()
|
||||
@ -366,62 +350,41 @@ public static function table(Table $table): Table
|
||||
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
|
||||
->emptyStateIcon('heroicon-o-document-arrow-down')
|
||||
->emptyStateActions([
|
||||
static::generatePackAction(name: 'generate_first', label: 'Generate first pack'),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('generate_first')
|
||||
->label('Generate first pack')
|
||||
->icon('heroicon-o-plus')
|
||||
->action(function (array $data): void {
|
||||
static::executeGeneration($data);
|
||||
})
|
||||
->form([
|
||||
Section::make('Pack options')
|
||||
->schema([
|
||||
Toggle::make('include_pii')
|
||||
->label('Include PII')
|
||||
->helperText('Include personally identifiable information in the export.')
|
||||
->default(config('tenantpilot.review_pack.include_pii_default', true)),
|
||||
Toggle::make('include_operations')
|
||||
->label('Include operations')
|
||||
->helperText('Include recent operation history in the export.')
|
||||
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
||||
]),
|
||||
])
|
||||
)
|
||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||
->apply(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function generatePackAction(string $name = 'generate_pack', string $label = 'Generate Pack'): Actions\Action
|
||||
{
|
||||
$action = UiEnforcement::forAction(
|
||||
Actions\Action::make($name)
|
||||
->label($label)
|
||||
->icon('heroicon-o-plus')
|
||||
->disabled(fn (): bool => static::reviewPackGenerationBlocked())
|
||||
->action(function (array $data): void {
|
||||
static::executeGeneration($data);
|
||||
})
|
||||
->form(static::reviewPackGenerationFormSchema())
|
||||
)
|
||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||
->preserveDisabled()
|
||||
->apply();
|
||||
|
||||
$action->tooltip(fn (): ?string => static::reviewPackGenerationActionTooltip());
|
||||
|
||||
return $action;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Section>
|
||||
*/
|
||||
public static function reviewPackGenerationFormSchema(): array
|
||||
{
|
||||
return [
|
||||
Section::make('Pack options')
|
||||
->schema([
|
||||
Toggle::make('include_pii')
|
||||
->label('Include PII')
|
||||
->helperText('Include personally identifiable information in the export.')
|
||||
->default(config('tenantpilot.review_pack.include_pii_default', true)),
|
||||
Toggle::make('include_operations')
|
||||
->label('Include operations')
|
||||
->helperText('Include recent operation history in the export.')
|
||||
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with(['tenant', 'operationRun', 'evidenceSnapshot', 'tenantReview'])
|
||||
->where('tenant_id', (int) $tenant->getKey());
|
||||
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
@ -432,36 +395,6 @@ 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
|
||||
{
|
||||
$presenter = app(ArtifactTruthPresenter::class);
|
||||
@ -525,14 +458,6 @@ public static function executeGeneration(array $data): void
|
||||
|
||||
try {
|
||||
$reviewPack = $service->generate($tenant, $user, $options);
|
||||
} catch (WorkspaceEntitlementBlockedException $exception) {
|
||||
Notification::make()
|
||||
->warning()
|
||||
->title('Review pack generation unavailable')
|
||||
->body($exception->getMessage())
|
||||
->send();
|
||||
|
||||
return;
|
||||
} catch (ReviewPackEvidenceResolutionException $exception) {
|
||||
$reasons = $exception->result->reasons;
|
||||
|
||||
@ -568,69 +493,4 @@ public static function executeGeneration(array $data): void
|
||||
|
||||
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
|
||||
{
|
||||
$tenant ??= Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
|
||||
}
|
||||
|
||||
public static function currentTenantContext(): ?Tenant
|
||||
{
|
||||
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
|
||||
{
|
||||
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
|
||||
{
|
||||
$decision = static::reviewPackGenerationDecision($tenant);
|
||||
|
||||
if (! (bool) ($decision['is_blocked'] ?? false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reason = $decision['block_reason'] ?? null;
|
||||
|
||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string
|
||||
{
|
||||
$decision = static::reviewPackGenerationDecision($tenant);
|
||||
|
||||
if (! (bool) ($decision['is_warning'] ?? false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reason = $decision['warning_reason'] ?? null;
|
||||
|
||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
|
||||
{
|
||||
$tenant ??= static::currentTenantContext();
|
||||
$user = auth()->user();
|
||||
|
||||
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant)) {
|
||||
return AuthUiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
return static::reviewPackGenerationBlockReason($tenant)
|
||||
?? static::reviewPackGenerationWarningReason($tenant);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,12 @@
|
||||
namespace App\Filament\Resources\ReviewPackResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Section;
|
||||
|
||||
class ListReviewPacks extends ListRecords
|
||||
{
|
||||
@ -12,13 +17,29 @@ class ListReviewPacks extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
ReviewPackResource::generatePackAction()
|
||||
->visible(fn (): bool => $this->tableHasRecords()),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('generate_pack')
|
||||
->label('Generate Pack')
|
||||
->icon('heroicon-o-plus')
|
||||
->action(function (array $data): void {
|
||||
ReviewPackResource::executeGeneration($data);
|
||||
})
|
||||
->form([
|
||||
Section::make('Pack options')
|
||||
->schema([
|
||||
Toggle::make('include_pii')
|
||||
->label('Include PII')
|
||||
->helperText('Include personally identifiable information in the export.')
|
||||
->default(config('tenantpilot.review_pack.include_pii_default', true)),
|
||||
Toggle::make('include_operations')
|
||||
->label('Include operations')
|
||||
->helperText('Include recent operation history in the export.')
|
||||
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
||||
]),
|
||||
])
|
||||
)
|
||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
|
||||
private function tableHasRecords(): bool
|
||||
{
|
||||
return $this->getTableRecords()->count() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,65 +19,6 @@ class ViewReviewPack extends ViewRecord
|
||||
|
||||
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(
|
||||
Actions\Action::make('regenerate')
|
||||
->label('Regenerate')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->disabled(fn (): bool => ReviewPackResource::reviewPackGenerationBlocked($this->record->tenant))
|
||||
->requiresConfirmation()
|
||||
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
|
||||
->action(function (array $data): void {
|
||||
/** @var ReviewPack $record */
|
||||
$record = $this->record;
|
||||
|
||||
$options = array_merge($record->options ?? [], [
|
||||
'include_pii' => (bool) ($data['include_pii'] ?? ($record->options['include_pii'] ?? true)),
|
||||
'include_operations' => (bool) ($data['include_operations'] ?? ($record->options['include_operations'] ?? true)),
|
||||
]);
|
||||
|
||||
ReviewPackResource::executeGeneration($options);
|
||||
})
|
||||
->form(function (): array {
|
||||
/** @var ReviewPack $record */
|
||||
$record = $this->record;
|
||||
$currentOptions = $record->options ?? [];
|
||||
|
||||
return [
|
||||
Section::make('Pack options')
|
||||
->schema([
|
||||
Toggle::make('include_pii')
|
||||
->label('Include PII')
|
||||
->helperText('Include personally identifiable information in the export.')
|
||||
->default((bool) ($currentOptions['include_pii'] ?? true)),
|
||||
Toggle::make('include_operations')
|
||||
->label('Include operations')
|
||||
->helperText('Include recent operation history in the export.')
|
||||
->default((bool) ($currentOptions['include_operations'] ?? true)),
|
||||
]),
|
||||
];
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||
->preserveDisabled()
|
||||
->apply();
|
||||
|
||||
$regenerateAction->tooltip(fn (): ?string => ReviewPackResource::reviewPackGenerationActionTooltip($this->record->tenant));
|
||||
|
||||
return [
|
||||
Actions\Action::make('download')
|
||||
->label('Download')
|
||||
@ -87,7 +28,46 @@ protected function getHeaderActions(): array
|
||||
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
|
||||
->openUrlInNewTab(),
|
||||
|
||||
$regenerateAction,
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('regenerate')
|
||||
->label('Regenerate')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
|
||||
->action(function (array $data): void {
|
||||
/** @var ReviewPack $record */
|
||||
$record = $this->record;
|
||||
|
||||
$options = array_merge($record->options ?? [], [
|
||||
'include_pii' => (bool) ($data['include_pii'] ?? ($record->options['include_pii'] ?? true)),
|
||||
'include_operations' => (bool) ($data['include_operations'] ?? ($record->options['include_operations'] ?? true)),
|
||||
]);
|
||||
|
||||
ReviewPackResource::executeGeneration($options);
|
||||
})
|
||||
->form(function (): array {
|
||||
/** @var ReviewPack $record */
|
||||
$record = $this->record;
|
||||
$currentOptions = $record->options ?? [];
|
||||
|
||||
return [
|
||||
Section::make('Pack options')
|
||||
->schema([
|
||||
Toggle::make('include_pii')
|
||||
->label('Include PII')
|
||||
->helperText('Include personally identifiable information in the export.')
|
||||
->default((bool) ($currentOptions['include_pii'] ?? true)),
|
||||
Toggle::make('include_operations')
|
||||
->label('Include operations')
|
||||
->helperText('Include recent operation history in the export.')
|
||||
->default((bool) ($currentOptions['include_operations'] ?? true)),
|
||||
]),
|
||||
];
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Pages\CrossTenantComparePage;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\TenantResource\Pages;
|
||||
use App\Filament\Resources\TenantResource\RelationManagers;
|
||||
@ -16,11 +15,9 @@
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\TenantTriageReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
use App\Services\Directory\RoleDefinitionsSyncService;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
@ -47,7 +44,6 @@
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -72,7 +68,6 @@
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms;
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -829,27 +824,6 @@ public static function table(Table $table): Table
|
||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
||||
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
||||
Actions\Action::make('compareTenants')
|
||||
->label('Compare tenants')
|
||||
->icon('heroicon-o-scale')
|
||||
->color('gray')
|
||||
->url(function (Tenant $record, mixed $livewire): string {
|
||||
$triageState = $livewire instanceof Pages\ListTenants
|
||||
? static::currentPortfolioTriageState($livewire)
|
||||
: [];
|
||||
|
||||
if (! static::hasActivePortfolioTriageState(
|
||||
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
|
||||
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
|
||||
static::sanitizeReviewStates($triageState['review_state'] ?? []),
|
||||
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
|
||||
)) {
|
||||
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
|
||||
}
|
||||
|
||||
return static::crossTenantCompareOpenUrl($record, $triageState);
|
||||
})
|
||||
->visible(fn (Tenant $record): bool => static::crossTenantCompareActionVisible($record)),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
@ -992,34 +966,6 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
Actions\BulkAction::make('compareSelected')
|
||||
->label('Compare selected')
|
||||
->icon('heroicon-o-scale')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => auth()->user() instanceof User)
|
||||
->authorize(fn (): bool => auth()->user() instanceof User)
|
||||
->extraAttributes(fn (mixed $livewire): array => [
|
||||
'x-bind:aria-disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire).' ? true : null',
|
||||
'x-bind:disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire),
|
||||
'x-bind:title' => static::crossTenantCompareBulkClientTooltipExpression($livewire),
|
||||
'x-bind:class' => "{ 'fi-disabled': ".static::crossTenantCompareBulkClientDisabledExpression($livewire).' }',
|
||||
])
|
||||
->action(function (Collection $records, mixed $livewire): void {
|
||||
$disabledReason = static::crossTenantCompareBulkDisabledReason($records);
|
||||
|
||||
if ($disabledReason !== null) {
|
||||
Notification::make()
|
||||
->title($disabledReason)
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (method_exists($livewire, 'redirect')) {
|
||||
$livewire->redirect(static::crossTenantCompareBulkOpenUrl($records, $livewire), navigate: true);
|
||||
}
|
||||
}),
|
||||
Actions\BulkAction::make('syncSelected')
|
||||
->label('Sync selected')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
@ -1212,52 +1158,6 @@ public static function tenantDashboardOpenUrl(Tenant $record, array $triageState
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
* recovery_evidence?: list<string>,
|
||||
* review_state?: list<string>,
|
||||
* triage_sort?: string|null
|
||||
* } $triageState
|
||||
*/
|
||||
public static function crossTenantCompareOpenUrl(Tenant $record, array $triageState = []): string
|
||||
{
|
||||
return static::crossTenantCompareOpenUrlForSelection(
|
||||
targetTenant: $record,
|
||||
triageState: $triageState,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
* recovery_evidence?: list<string>,
|
||||
* review_state?: list<string>,
|
||||
* triage_sort?: string|null
|
||||
* } $triageState
|
||||
*/
|
||||
public static function crossTenantCompareOpenUrlForSelection(
|
||||
Tenant $targetTenant,
|
||||
array $triageState = [],
|
||||
?Tenant $sourceTenant = null,
|
||||
): string {
|
||||
$normalizedState = static::portfolioReturnFilters(
|
||||
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
|
||||
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
|
||||
static::sanitizeReviewStates($triageState['review_state'] ?? []),
|
||||
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
|
||||
);
|
||||
|
||||
return CrossTenantComparePage::launchUrl(
|
||||
sourceTenant: $sourceTenant,
|
||||
targetTenant: $targetTenant,
|
||||
navigationContext: CanonicalNavigationContext::forTenantRegistry(
|
||||
backLinkUrl: static::getUrl(panel: 'admin', parameters: $normalizedState),
|
||||
tenantId: $sourceTenant instanceof Tenant ? null : (int) $targetTenant->getKey(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
@ -1348,168 +1248,6 @@ private static function portfolioReturnFiltersFromRequest(array $query): array
|
||||
);
|
||||
}
|
||||
|
||||
private static function crossTenantCompareActionVisible(Tenant $record): bool
|
||||
{
|
||||
if (! $record->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId) || $workspaceId !== (int) $record->workspace_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $workspaceResolver */
|
||||
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $workspaceResolver->isMember($user, $workspace)
|
||||
|| ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $tenantResolver */
|
||||
$tenantResolver = app(CapabilityResolver::class);
|
||||
|
||||
return $user->canAccessTenant($record)
|
||||
&& $tenantResolver->can($user, $record, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
private static function crossTenantCompareBulkDisabledReason(Collection $records): ?string
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return UiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
$tenants = $records
|
||||
->filter(fn ($record): bool => $record instanceof Tenant)
|
||||
->values();
|
||||
|
||||
if ($records->count() !== 2 || $tenants->count() !== 2) {
|
||||
return 'Select exactly two tenants to compare.';
|
||||
}
|
||||
|
||||
if ($tenants->contains(fn (Tenant $tenant): bool => ! $tenant->isActive())) {
|
||||
return 'Only active tenants can be compared.';
|
||||
}
|
||||
|
||||
$workspaceIds = $tenants
|
||||
->map(fn (Tenant $tenant): int => (int) $tenant->workspace_id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($workspaceIds->count() !== 1) {
|
||||
return UiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
$workspaceId = $workspaceIds->first();
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return UiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $workspaceResolver */
|
||||
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $workspaceResolver->isMember($user, $workspace)
|
||||
|| ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
|
||||
return UiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $tenantResolver */
|
||||
$tenantResolver = app(CapabilityResolver::class);
|
||||
|
||||
$isDenied = $tenants->contains(fn (Tenant $tenant): bool => ! $user->canAccessTenant($tenant)
|
||||
|| ! $tenantResolver->can($user, $tenant, Capabilities::TENANT_VIEW));
|
||||
|
||||
return $isDenied ? UiTooltips::insufficientPermission() : null;
|
||||
}
|
||||
|
||||
private static function crossTenantCompareBulkClientDisabledExpression(mixed $livewire): string
|
||||
{
|
||||
$containsInactiveSelection = static::crossTenantCompareBulkContainsInactiveSelectionExpression($livewire);
|
||||
|
||||
return "getSelectedRecordsCount() !== 2 || {$containsInactiveSelection}";
|
||||
}
|
||||
|
||||
private static function crossTenantCompareBulkClientTooltipExpression(mixed $livewire): string
|
||||
{
|
||||
$containsInactiveSelection = static::crossTenantCompareBulkContainsInactiveSelectionExpression($livewire);
|
||||
|
||||
return "getSelectedRecordsCount() !== 2 ? 'Select exactly two tenants to compare.' : ({$containsInactiveSelection} ? 'Only active tenants can be compared.' : null)";
|
||||
}
|
||||
|
||||
private static function crossTenantCompareBulkContainsInactiveSelectionExpression(mixed $livewire): string
|
||||
{
|
||||
$inactiveRecordKeys = \Illuminate\Support\Js::from(static::crossTenantCompareInactiveSelectionRecordKeys($livewire));
|
||||
|
||||
return "[...selectedRecords].some((key) => {$inactiveRecordKeys}.includes(key))";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function crossTenantCompareInactiveSelectionRecordKeys(mixed $livewire): array
|
||||
{
|
||||
if (! $livewire instanceof HasTable || ! method_exists($livewire, 'getTableRecordKey')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tableRecords = $livewire->getTableRecords();
|
||||
|
||||
if (method_exists($tableRecords, 'getCollection')) {
|
||||
$tableRecords = $tableRecords->getCollection();
|
||||
}
|
||||
|
||||
return collect($tableRecords)
|
||||
->filter(fn ($record): bool => $record instanceof Tenant && ! $record->isActive())
|
||||
->map(fn (Tenant $tenant): string => (string) $livewire->getTableRecordKey($tenant))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private static function crossTenantCompareBulkOpenUrl(Collection $records, mixed $livewire): string
|
||||
{
|
||||
$triageState = $livewire instanceof Pages\ListTenants
|
||||
? static::currentPortfolioTriageState($livewire)
|
||||
: [];
|
||||
|
||||
if (! static::hasActivePortfolioTriageState(
|
||||
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
|
||||
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
|
||||
static::sanitizeReviewStates($triageState['review_state'] ?? []),
|
||||
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
|
||||
)) {
|
||||
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
|
||||
}
|
||||
|
||||
$tenants = $records
|
||||
->filter(fn ($record): bool => $record instanceof Tenant)
|
||||
->values();
|
||||
|
||||
return static::crossTenantCompareOpenUrlForSelection(
|
||||
targetTenant: $tenants->get(1),
|
||||
triageState: $triageState,
|
||||
sourceTenant: $tenants->get(0),
|
||||
);
|
||||
}
|
||||
|
||||
private static function hasActivePortfolioTriageState(
|
||||
array $backupPostures,
|
||||
array $recoveryEvidence,
|
||||
|
||||
@ -6,9 +6,7 @@
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\TenantReviewResource\Pages;
|
||||
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
@ -17,7 +15,6 @@
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
@ -85,26 +82,6 @@ public static function shouldRegisterNavigation(): bool
|
||||
return Filament::getCurrentPanel()?->getId() === 'tenant';
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return __('localization.review.reporting');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('localization.review.reviews');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('localization.review.review');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('localization.review.reviews');
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -173,7 +150,7 @@ public static function form(Schema $schema): Schema
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make(__('localization.review.outcome_summary'))
|
||||
Section::make('Outcome summary')
|
||||
->schema([
|
||||
ViewEntry::make('artifact_truth')
|
||||
->hiddenLabel()
|
||||
@ -182,7 +159,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make(__('localization.review.review'))
|
||||
Section::make('Review')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
@ -191,23 +168,23 @@ public static function infolist(Schema $schema): Schema
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
||||
TextEntry::make('completeness_state')
|
||||
->label(__('localization.review.completeness'))
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||
TextEntry::make('tenant.name')->label(__('localization.review.tenant')),
|
||||
TextEntry::make('tenant.name')->label('Tenant'),
|
||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('published_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('evidenceSnapshot.id')
|
||||
->label(__('localization.review.evidence_snapshot'))
|
||||
->label('Evidence snapshot')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||
: null),
|
||||
TextEntry::make('currentExportReviewPack.id')
|
||||
->label(__('localization.review.current_export'))
|
||||
->label('Current export')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
|
||||
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
|
||||
@ -215,14 +192,13 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('fingerprint')
|
||||
->copyable()
|
||||
->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceMode())
|
||||
->columnSpanFull()
|
||||
->fontFamily('mono')
|
||||
->size(TextSize::ExtraSmall),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make(__('localization.review.executive_posture'))
|
||||
Section::make('Executive posture')
|
||||
->schema([
|
||||
ViewEntry::make('review_summary')
|
||||
->hiddenLabel()
|
||||
@ -231,21 +207,21 @@ public static function infolist(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make(__('localization.review.sections'))
|
||||
Section::make('Sections')
|
||||
->schema([
|
||||
RepeatableEntry::make('sections')
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
TextEntry::make('title'),
|
||||
TextEntry::make('completeness_state')
|
||||
->label(__('localization.review.completeness'))
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||
TextEntry::make('measured_at')->dateTime()->placeholder('—'),
|
||||
Section::make(__('localization.review.details'))
|
||||
Section::make('Details')
|
||||
->schema([
|
||||
ViewEntry::make('section_payload')
|
||||
->hiddenLabel()
|
||||
@ -265,25 +241,6 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
$exportExecutivePackAction = UiEnforcement::forTableAction(
|
||||
Actions\Action::make('export_executive_pack')
|
||||
->label(__('localization.review.export_executive_pack'))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
||||
TenantReviewStatus::Ready->value,
|
||||
TenantReviewStatus::Published->value,
|
||||
], true))
|
||||
->disabled(fn (TenantReview $record): bool => static::reviewPackGenerationBlocked($record->tenant))
|
||||
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
|
||||
fn (TenantReview $record): TenantReview => $record,
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->preserveDisabled()
|
||||
->apply();
|
||||
|
||||
$exportExecutivePackAction->tooltip(fn (TenantReview $record): ?string => static::reviewPackGenerationActionTooltip($record->tenant));
|
||||
|
||||
return $table
|
||||
->defaultSort('generated_at', 'desc')
|
||||
->persistFiltersInSession()
|
||||
@ -299,7 +256,7 @@ public static function table(Table $table): Table
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('outcome')
|
||||
->label(__('localization.review.outcome'))
|
||||
->label('Outcome')
|
||||
->badge()
|
||||
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryLabel)
|
||||
->color(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryBadge->color)
|
||||
@ -310,10 +267,10 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
||||
->label(__('localization.review.export'))
|
||||
->label('Export')
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('next_step')
|
||||
->label(__('localization.review.next_step'))
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->nextActionText)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('fingerprint')
|
||||
@ -327,18 +284,31 @@ public static function table(Table $table): Table
|
||||
->all()),
|
||||
Tables\Filters\SelectFilter::make('completeness_state')
|
||||
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
||||
\App\Support\Filament\FilterPresets::dateRange('review_date', __('localization.review.review_date'), 'generated_at'),
|
||||
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||
])
|
||||
->actions([
|
||||
$exportExecutivePackAction,
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('export_executive_pack')
|
||||
->label('Export executive pack')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
||||
TenantReviewStatus::Ready->value,
|
||||
TenantReviewStatus::Published->value,
|
||||
], true))
|
||||
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
|
||||
fn (TenantReview $record): TenantReview => $record,
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading(__('localization.review.no_tenant_reviews_yet'))
|
||||
->emptyStateDescription(__('localization.review.create_first_review_description'))
|
||||
->emptyStateHeading('No tenant reviews yet')
|
||||
->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.')
|
||||
->emptyStateActions([
|
||||
static::makeCreateReviewAction(
|
||||
name: 'create_first_review',
|
||||
label: __('localization.review.create_first_review'),
|
||||
label: 'Create first review',
|
||||
icon: 'heroicon-o-plus',
|
||||
),
|
||||
]);
|
||||
@ -357,23 +327,19 @@ public static function makeCreateReviewAction(
|
||||
string $label = 'Create review',
|
||||
string $icon = 'heroicon-o-plus',
|
||||
): Actions\Action {
|
||||
$label = $label === 'Create review'
|
||||
? __('localization.review.create_review')
|
||||
: $label;
|
||||
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make($name)
|
||||
->label($label)
|
||||
->icon($icon)
|
||||
->form([
|
||||
Section::make(__('localization.review.evidence_basis'))
|
||||
Section::make('Evidence basis')
|
||||
->schema([
|
||||
Select::make('evidence_snapshot_id')
|
||||
->label(__('localization.review.evidence_snapshot'))
|
||||
->label('Evidence snapshot')
|
||||
->required()
|
||||
->options(fn (): array => static::evidenceSnapshotOptions())
|
||||
->searchable()
|
||||
->helperText(__('localization.review.evidence_basis_helper')),
|
||||
->helperText('Choose the anchored evidence snapshot for this review.'),
|
||||
]),
|
||||
])
|
||||
->action(fn (array $data): mixed => static::executeCreateReview($data)),
|
||||
@ -391,7 +357,7 @@ public static function executeCreateReview(array $data): void
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
Notification::make()->danger()->title(__('localization.review.unable_create_missing_context'))->send();
|
||||
Notification::make()->danger()->title('Unable to create review — missing context.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -413,7 +379,7 @@ public static function executeCreateReview(array $data): void
|
||||
: null;
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
Notification::make()->danger()->title(__('localization.review.select_valid_evidence_snapshot'))->send();
|
||||
Notification::make()->danger()->title('Select a valid evidence snapshot.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -421,7 +387,7 @@ public static function executeCreateReview(array $data): void
|
||||
try {
|
||||
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()->danger()->title(__('localization.review.unable_create_review'))->body($throwable->getMessage())->send();
|
||||
Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -431,11 +397,11 @@ public static function executeCreateReview(array $data): void
|
||||
if (! $review->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(__('localization.review.review_already_available'))
|
||||
->body(__('localization.review.review_already_available_body'))
|
||||
->title('Review already available')
|
||||
->body('A matching mutable review already exists for this evidence basis.')
|
||||
->actions([
|
||||
Actions\Action::make('view_review')
|
||||
->label(__('localization.review.view_review'))
|
||||
->label('View review')
|
||||
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -444,12 +410,12 @@ public static function executeCreateReview(array $data): void
|
||||
}
|
||||
|
||||
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
|
||||
->body(__('localization.review.review_composing_background'));
|
||||
->body('The review is being composed in the background.');
|
||||
|
||||
if ($review->operation_run_id) {
|
||||
$toast->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label(__('localization.review.open_operation'))
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
|
||||
]);
|
||||
}
|
||||
@ -457,71 +423,13 @@ public static function executeCreateReview(array $data): void
|
||||
$toast->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
|
||||
{
|
||||
$tenant ??= Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
|
||||
{
|
||||
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
|
||||
{
|
||||
$decision = static::reviewPackGenerationDecision($tenant);
|
||||
|
||||
if (! (bool) ($decision['is_blocked'] ?? false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reason = $decision['block_reason'] ?? null;
|
||||
|
||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string
|
||||
{
|
||||
$decision = static::reviewPackGenerationDecision($tenant);
|
||||
|
||||
if (! (bool) ($decision['is_warning'] ?? false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reason = $decision['warning_reason'] ?? null;
|
||||
|
||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
||||
}
|
||||
|
||||
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
|
||||
{
|
||||
$tenant ??= static::panelTenantContext();
|
||||
$user = auth()->user();
|
||||
|
||||
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
|
||||
return AuthUiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
return static::reviewPackGenerationBlockReason($tenant)
|
||||
?? static::reviewPackGenerationWarningReason($tenant);
|
||||
}
|
||||
|
||||
public static function executeExport(TenantReview $review): void
|
||||
{
|
||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
|
||||
Notification::make()->danger()->title(__('localization.review.unable_export_missing_context'))->send();
|
||||
Notification::make()->danger()->title('Unable to export review — missing context.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -538,7 +446,7 @@ public static function executeExport(TenantReview $review): void
|
||||
|
||||
if ($service->checkActiveRunForReview($review)) {
|
||||
OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value)
|
||||
->body(__('localization.review.export_already_queued_body'))
|
||||
->body('An executive pack export is already queued or running for this review.')
|
||||
->send();
|
||||
|
||||
return;
|
||||
@ -549,12 +457,8 @@ public static function executeExport(TenantReview $review): void
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
]);
|
||||
} catch (WorkspaceEntitlementBlockedException $exception) {
|
||||
Notification::make()->warning()->title(__('localization.review.executive_pack_export_unavailable'))->body($exception->getMessage())->send();
|
||||
|
||||
return;
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()->danger()->title(__('localization.review.unable_export_executive_pack'))->body($throwable->getMessage())->send();
|
||||
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -565,11 +469,11 @@ public static function executeExport(TenantReview $review): void
|
||||
if (! $pack->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(__('localization.review.executive_pack_already_available'))
|
||||
->body(__('localization.review.executive_pack_already_available_body'))
|
||||
->title('Executive pack already available')
|
||||
->body('A matching executive pack already exists for this review.')
|
||||
->actions([
|
||||
Actions\Action::make('view_pack')
|
||||
->label(__('localization.review.view_pack'))
|
||||
->label('View pack')
|
||||
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -578,7 +482,7 @@ public static function executeExport(TenantReview $review): void
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
|
||||
->body(__('localization.review.executive_pack_generating_background'))
|
||||
->body('The executive pack is being generated in the background.')
|
||||
->send();
|
||||
}
|
||||
|
||||
@ -618,7 +522,7 @@ private static function evidenceSnapshotOptions(): array
|
||||
'#%d · %s · %s',
|
||||
(int) $snapshot->getKey(),
|
||||
BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label,
|
||||
$snapshot->generated_at?->format('Y-m-d H:i') ?? __('localization.review.pending')
|
||||
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
|
||||
),
|
||||
])
|
||||
->all();
|
||||
@ -642,89 +546,59 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
||||
|
||||
if ($findingOutcomeSummary !== null) {
|
||||
$highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.';
|
||||
$highlights[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.';
|
||||
}
|
||||
|
||||
return [
|
||||
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
|
||||
'compressed_outcome' => static::compressedOutcome($record)->toArray(),
|
||||
'customer_workspace_mode' => static::isCustomerWorkspaceMode(),
|
||||
'reason_semantics' => static::isCustomerWorkspaceMode()
|
||||
? []
|
||||
: $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
|
||||
'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
|
||||
'highlights' => $highlights,
|
||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||
'context_links' => static::summaryContextLinks($record, static::isCustomerWorkspaceMode()),
|
||||
'metrics' => static::isCustomerWorkspaceMode() ? [
|
||||
['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||
['label' => __('localization.review.pending_verification'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
|
||||
['label' => __('localization.review.verified_cleared'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
|
||||
] : [
|
||||
['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_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.sections'), 'value' => (string) ($summary['section_count'] ?? 0)],
|
||||
['label' => __('localization.review.pending_verification'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
|
||||
['label' => __('localization.review.verified_cleared'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
|
||||
'context_links' => static::summaryContextLinks($record),
|
||||
'metrics' => [
|
||||
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
|
||||
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
|
||||
['label' => 'Pending verification', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
|
||||
['label' => 'Verified cleared', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{title:string,label:string,url:?string,description:string}>
|
||||
* @return array<int, array{title:string,label:string,url:string,description:string}>
|
||||
*/
|
||||
private static function summaryContextLinks(TenantReview $record, bool $customerWorkspaceMode = false): array
|
||||
private static function summaryContextLinks(TenantReview $record): array
|
||||
{
|
||||
$links = [];
|
||||
|
||||
if (! $customerWorkspaceMode && is_numeric($record->operation_run_id)) {
|
||||
if (is_numeric($record->operation_run_id)) {
|
||||
$links[] = [
|
||||
'title' => __('localization.review.operation'),
|
||||
'label' => __('localization.review.open_operation'),
|
||||
'title' => 'Operation',
|
||||
'label' => 'Open operation',
|
||||
'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id),
|
||||
'description' => __('localization.review.operation_description'),
|
||||
'description' => 'Inspect the latest review composition or refresh run.',
|
||||
];
|
||||
}
|
||||
|
||||
if (! $customerWorkspaceMode && $record->currentExportReviewPack && $record->tenant) {
|
||||
if ($record->currentExportReviewPack && $record->tenant) {
|
||||
$links[] = [
|
||||
'title' => __('localization.review.executive_pack'),
|
||||
'label' => __('localization.review.view_executive_pack'),
|
||||
'title' => 'Executive pack',
|
||||
'label' => 'View executive pack',
|
||||
'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant),
|
||||
'description' => __('localization.review.executive_pack_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($record->tenant) {
|
||||
$links[] = [
|
||||
'title' => __('localization.review.customer_workspace'),
|
||||
'label' => __('localization.review.open_customer_workspace'),
|
||||
'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
|
||||
'description' => __('localization.review.customer_workspace_description'),
|
||||
'description' => 'Open the current export that belongs to this review.',
|
||||
];
|
||||
}
|
||||
|
||||
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, [
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
]);
|
||||
}
|
||||
|
||||
$links[] = [
|
||||
'title' => __('localization.review.evidence_snapshot'),
|
||||
'label' => __('localization.review.view_evidence_snapshot'),
|
||||
'url' => $evidenceUrl,
|
||||
'description' => $canViewEvidence
|
||||
? __('localization.review.evidence_snapshot_description')
|
||||
: __('localization.review.evidence_proof_access_unavailable'),
|
||||
'title' => 'Evidence snapshot',
|
||||
'label' => 'View evidence snapshot',
|
||||
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
|
||||
'description' => 'Return to the evidence basis behind this review.',
|
||||
];
|
||||
}
|
||||
|
||||
@ -805,21 +679,4 @@ private static function findingOutcomeSummary(array $summary): ?string
|
||||
|
||||
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
|
||||
}
|
||||
|
||||
private static function isCustomerWorkspaceMode(): bool
|
||||
{
|
||||
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,20 +4,14 @@
|
||||
|
||||
namespace App\Filament\Resources\TenantReviewResource\Pages;
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use Filament\Actions;
|
||||
@ -30,13 +24,6 @@ class ViewTenantReview extends ViewRecord
|
||||
{
|
||||
protected static string $resource = TenantReviewResource::class;
|
||||
|
||||
public function mount(int|string $record): void
|
||||
{
|
||||
parent::mount($record);
|
||||
|
||||
$this->auditCustomerWorkspaceOpen();
|
||||
}
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return TenantReviewResource::resolveScopedRecordOrFail($key);
|
||||
@ -67,12 +54,6 @@ protected function authorizeAccess(): void
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
if ($this->isCustomerWorkspaceView()) {
|
||||
return [
|
||||
$this->downloadCurrentReviewPackAction(),
|
||||
];
|
||||
}
|
||||
|
||||
$secondaryActions = $this->secondaryLifecycleActions();
|
||||
|
||||
return array_values(array_filter([
|
||||
@ -88,7 +69,7 @@ protected function getHeaderActions(): array
|
||||
->label('Danger')
|
||||
->icon('heroicon-o-archive-box')
|
||||
->color('danger')
|
||||
->visible(fn (): bool => ! $this->isCustomerWorkspaceView() && ! $this->record->statusEnum()->isTerminal()),
|
||||
->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
|
||||
]));
|
||||
}
|
||||
|
||||
@ -104,10 +85,6 @@ private function primaryLifecycleAction(): ?Actions\Action
|
||||
|
||||
private function primaryLifecycleActionName(): ?string
|
||||
{
|
||||
if ($this->isCustomerWorkspaceView()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
|
||||
return 'export_executive_pack';
|
||||
}
|
||||
@ -145,10 +122,6 @@ private function secondaryLifecycleActions(): array
|
||||
*/
|
||||
private function secondaryLifecycleActionNames(): array
|
||||
{
|
||||
if ($this->isCustomerWorkspaceView()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = [];
|
||||
|
||||
if ($this->record->isMutable()) {
|
||||
@ -205,6 +178,7 @@ private function refreshReviewAction(): Actions\Action
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply();
|
||||
}
|
||||
|
||||
@ -258,7 +232,7 @@ private function publishReviewAction(): Actions\Action
|
||||
|
||||
private function exportExecutivePackAction(): Actions\Action
|
||||
{
|
||||
$action = UiEnforcement::forAction(
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('export_executive_pack')
|
||||
->label('Export executive pack')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
@ -267,17 +241,11 @@ private function exportExecutivePackAction(): Actions\Action
|
||||
TenantReviewStatus::Ready->value,
|
||||
TenantReviewStatus::Published->value,
|
||||
], true))
|
||||
->disabled(fn (): bool => TenantReviewResource::reviewPackGenerationBlocked($this->record->tenant))
|
||||
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->preserveDisabled()
|
||||
->apply();
|
||||
|
||||
$action->tooltip(fn (): ?string => TenantReviewResource::reviewPackGenerationActionTooltip($this->record->tenant));
|
||||
|
||||
return $action;
|
||||
}
|
||||
|
||||
private function createNextReviewAction(): Actions\Action
|
||||
@ -351,107 +319,4 @@ private function archiveReviewAction(): Actions\Action
|
||||
->preserveVisibility()
|
||||
->apply();
|
||||
}
|
||||
|
||||
private function downloadCurrentReviewPackAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('download_current_review_pack')
|
||||
->label(__('localization.review.download_current_review_pack'))
|
||||
->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,
|
||||
]);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
||||
}
|
||||
|
||||
private function auditCustomerWorkspaceOpen(): void
|
||||
{
|
||||
if (! $this->isCustomerWorkspaceView()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$tenant = $this->record->tenant;
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::TenantReviewOpened,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'review_id' => (int) $this->record->getKey(),
|
||||
'source_surface' => 'customer_review_workspace',
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'tenant_review',
|
||||
resourceId: (string) $this->record->getKey(),
|
||||
targetLabel: sprintf('Tenant review #%d', (int) $this->record->getKey()),
|
||||
tenant: $tenant,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,9 +6,6 @@
|
||||
|
||||
use App\Filament\System\Widgets\ControlTowerHealthIndicator;
|
||||
use App\Filament\System\Widgets\ControlTowerKpis;
|
||||
use App\Filament\System\Widgets\CustomerHealthKpis;
|
||||
use App\Filament\System\Widgets\CustomerHealthTopWorkspaces;
|
||||
use App\Filament\System\Widgets\ProductTelemetryKpis;
|
||||
use App\Filament\System\Widgets\ControlTowerRecentFailures;
|
||||
use App\Filament\System\Widgets\ControlTowerTopOffenders;
|
||||
use App\Models\PlatformUser;
|
||||
@ -28,11 +25,6 @@ class Dashboard extends BaseDashboard
|
||||
{
|
||||
public string $window = SystemConsoleWindow::LastDay;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('localization.dashboard.system_title');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $parameters
|
||||
*/
|
||||
@ -69,24 +61,9 @@ public function getWidgets(): array
|
||||
{
|
||||
return [
|
||||
ControlTowerHealthIndicator::class,
|
||||
new WidgetConfiguration(CustomerHealthKpis::class, [
|
||||
'window' => $this->window,
|
||||
]),
|
||||
new WidgetConfiguration(CustomerHealthTopWorkspaces::class, [
|
||||
'window' => $this->window,
|
||||
]),
|
||||
new WidgetConfiguration(ControlTowerKpis::class, [
|
||||
'window' => $this->window,
|
||||
]),
|
||||
new WidgetConfiguration(ProductTelemetryKpis::class, [
|
||||
'window' => $this->window,
|
||||
]),
|
||||
new WidgetConfiguration(ControlTowerTopOffenders::class, [
|
||||
'window' => $this->window,
|
||||
]),
|
||||
new WidgetConfiguration(ControlTowerRecentFailures::class, [
|
||||
'window' => $this->window,
|
||||
]),
|
||||
ControlTowerKpis::class,
|
||||
ControlTowerTopOffenders::class,
|
||||
ControlTowerRecentFailures::class,
|
||||
];
|
||||
}
|
||||
|
||||
@ -114,12 +91,12 @@ protected function getHeaderActions(): array
|
||||
|
||||
return [
|
||||
Action::make('set_window')
|
||||
->label(__('localization.dashboard.time_window'))
|
||||
->label('Time window')
|
||||
->icon('heroicon-o-clock')
|
||||
->color('gray')
|
||||
->form([
|
||||
Select::make('window')
|
||||
->label(__('localization.dashboard.window'))
|
||||
->label('Window')
|
||||
->options(SystemConsoleWindow::options())
|
||||
->default($this->window)
|
||||
->required(),
|
||||
@ -135,7 +112,7 @@ protected function getHeaderActions(): array
|
||||
}),
|
||||
|
||||
Action::make('enter_break_glass')
|
||||
->label(__('localization.dashboard.enter_break_glass'))
|
||||
->label('Enter break-glass mode')
|
||||
->color('danger')
|
||||
->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive())
|
||||
->requiresConfirmation()
|
||||
@ -163,13 +140,13 @@ protected function getHeaderActions(): array
|
||||
$breakGlass->start($user, (string) ($data['reason'] ?? ''));
|
||||
|
||||
Notification::make()
|
||||
->title(__('localization.dashboard.recovery_mode_enabled'))
|
||||
->title('Recovery mode enabled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('exit_break_glass')
|
||||
->label(__('localization.dashboard.exit_break_glass'))
|
||||
->label('Exit break-glass')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive())
|
||||
->requiresConfirmation()
|
||||
@ -185,7 +162,7 @@ protected function getHeaderActions(): array
|
||||
$breakGlass->exit($user);
|
||||
|
||||
Notification::make()
|
||||
->title(__('localization.dashboard.recovery_mode_ended'))
|
||||
->title('Recovery mode ended')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
@ -1,144 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Directory\Concerns;
|
||||
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\CustomerHealth\CustomerHealthDimensionCatalog;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
|
||||
trait BuildsCustomerHealthDecisionData
|
||||
{
|
||||
/**
|
||||
* @param array{
|
||||
* overall_level: string,
|
||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
||||
* dominant_dimension_keys: list<string>
|
||||
* } $summary
|
||||
* @return array{
|
||||
* overall: array{label: string, color: string, icon: string|null},
|
||||
* reason: string,
|
||||
* impact: string,
|
||||
* recommended_action: string,
|
||||
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
|
||||
* window_label: string
|
||||
* }
|
||||
*/
|
||||
protected function buildCustomerHealthDecision(array $summary, SystemConsoleWindow $window, string $surface): array
|
||||
{
|
||||
$overallBadge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $summary['overall_level']);
|
||||
|
||||
$dominantDimensions = collect($summary['dominant_dimension_keys'])
|
||||
->map(function (string $dimensionKey) use ($summary): ?array {
|
||||
$dimension = $summary['dimensions'][$dimensionKey] ?? null;
|
||||
|
||||
if (! is_array($dimension)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$badge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $dimension['level']);
|
||||
|
||||
return [
|
||||
'label' => $dimension['label'],
|
||||
'color' => $badge->color,
|
||||
'icon' => $badge->icon,
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->take(2)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$dominantLabels = array_map(static fn (array $dimension): string => $dimension['label'], $dominantDimensions);
|
||||
$primaryDimension = $summary['dominant_dimension_keys'][0] ?? null;
|
||||
|
||||
return [
|
||||
'overall' => [
|
||||
'label' => $overallBadge->label,
|
||||
'color' => $overallBadge->color,
|
||||
'icon' => $overallBadge->icon,
|
||||
],
|
||||
'reason' => $this->customerHealthReason($dominantLabels),
|
||||
'impact' => $this->customerHealthImpact($summary['overall_level'], $primaryDimension),
|
||||
'recommended_action' => $this->customerHealthRecommendedAction($summary['overall_level'], $primaryDimension, $surface),
|
||||
'dominant_dimensions' => $dominantDimensions,
|
||||
'window_label' => SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $dominantLabels
|
||||
*/
|
||||
protected function customerHealthReason(array $dominantLabels): string
|
||||
{
|
||||
if ($dominantLabels === []) {
|
||||
return 'No active health drivers are pressuring this workspace right now.';
|
||||
}
|
||||
|
||||
$labelPrefix = count($dominantLabels) === 1 ? 'Top driver' : 'Top drivers';
|
||||
|
||||
return $labelPrefix.': '.implode(', ', $dominantLabels);
|
||||
}
|
||||
|
||||
protected function customerHealthImpact(string $overallLevel, ?string $primaryDimension): string
|
||||
{
|
||||
if ($overallLevel === 'ok') {
|
||||
return 'Tracked onboarding, provider, operational, governance, review-pack, and engagement signals are currently stable.';
|
||||
}
|
||||
|
||||
if ($overallLevel === 'unknown') {
|
||||
return 'Some required health truth is missing or stale, so this workspace cannot be treated as healthy yet.';
|
||||
}
|
||||
|
||||
return match ($primaryDimension) {
|
||||
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $overallLevel === 'critical'
|
||||
? 'Onboarding readiness is blocked, so this workspace cannot be treated as operationally ready.'
|
||||
: 'Onboarding readiness still needs follow-up before this workspace can be treated as fully stable.',
|
||||
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $overallLevel === 'critical'
|
||||
? 'Default provider consent or verification is blocking reliable tenant management.'
|
||||
: 'Provider connectivity has degraded and may impact reliable tenant management.',
|
||||
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => $overallLevel === 'critical'
|
||||
? 'Failed or stuck operations are actively putting delivery at risk.'
|
||||
: 'Recent operational noise is starting to erode delivery confidence.',
|
||||
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $overallLevel === 'critical'
|
||||
? 'High-severity or expired governance pressure needs immediate review.'
|
||||
: 'Governance pressure is active and should be reviewed before it escalates.',
|
||||
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => $overallLevel === 'critical'
|
||||
? 'Recent review-pack work is unusable or expired, so review readiness is blocked.'
|
||||
: 'Review-pack readiness is incomplete, so recent review evidence may not be usable yet.',
|
||||
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $overallLevel === 'critical'
|
||||
? 'Recent product activity is missing, which suggests active usage may be deteriorating.'
|
||||
: 'Recent product activity is thinning out and may indicate adoption drift.',
|
||||
default => $overallLevel === 'critical'
|
||||
? 'This workspace needs immediate operator follow-up.'
|
||||
: 'This workspace needs follow-up soon to prevent further drift.',
|
||||
};
|
||||
}
|
||||
|
||||
protected function customerHealthRecommendedAction(string $overallLevel, ?string $primaryDimension, string $surface): string
|
||||
{
|
||||
if ($overallLevel === 'ok') {
|
||||
return 'Continue normal monitoring from the system dashboard.';
|
||||
}
|
||||
|
||||
return match ($primaryDimension) {
|
||||
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $surface === 'tenant'
|
||||
? 'Confirm the tenant onboarding state with the responsible tenant admin and clear the blocking step.'
|
||||
: 'Open the affected tenant below and confirm which onboarding step is blocked.',
|
||||
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $surface === 'tenant'
|
||||
? 'Review connectivity signals below and confirm the default provider consent and verification state.'
|
||||
: 'Open the affected tenant below and review the default provider connection state.',
|
||||
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => 'Review recent operations below and triage failed or stuck runs first.',
|
||||
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $surface === 'tenant'
|
||||
? 'Review governance findings or exception pressure for this tenant before proceeding.'
|
||||
: 'Open the affected tenant below and review governance findings or exceptions.',
|
||||
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => 'Check recent review-pack activity and confirm that a usable pack exists for the current window.',
|
||||
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $surface === 'tenant'
|
||||
? 'Confirm whether missing recent product activity is expected for this tenant.'
|
||||
: 'Confirm whether missing recent product activity is expected across this workspace.',
|
||||
default => 'Review the diagnostics below to confirm which source truth needs operator follow-up.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -4,25 +4,20 @@
|
||||
|
||||
namespace App\Filament\System\Pages\Directory;
|
||||
|
||||
use App\Filament\System\Pages\Directory\Concerns\BuildsCustomerHealthDecisionData;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPermission;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\System\SystemDirectoryLinks;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ViewTenant extends Page
|
||||
{
|
||||
use BuildsCustomerHealthDecisionData;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'directory/tenants/{tenant}';
|
||||
@ -107,26 +102,4 @@ public function runsUrl(): string
|
||||
{
|
||||
return SystemOperationRunLinks::index();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* overall: array{label: string, color: string, icon: string|null},
|
||||
* reason: string,
|
||||
* impact: string,
|
||||
* recommended_action: string,
|
||||
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
|
||||
* window_label: string
|
||||
* }|null
|
||||
*/
|
||||
public function customerHealthDecision(): ?array
|
||||
{
|
||||
$window = SystemConsoleWindow::fromNullable(request()->query('window'));
|
||||
$summary = app(WorkspaceHealthSummaryQuery::class)->summaryForWorkspace((int) $this->tenant->workspace_id, $window);
|
||||
|
||||
if (! is_array($summary)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildCustomerHealthDecision($summary, $window, 'tenant');
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,31 +4,19 @@
|
||||
|
||||
namespace App\Filament\System\Pages\Directory;
|
||||
|
||||
use App\Filament\System\Pages\Directory\Concerns\BuildsCustomerHealthDecisionData;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\System\SystemDirectoryLinks;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ViewWorkspace extends Page
|
||||
{
|
||||
use BuildsCustomerHealthDecisionData;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'directory/workspaces/{workspace}';
|
||||
@ -91,105 +79,4 @@ public function runsUrl(): string
|
||||
{
|
||||
return SystemOperationRunLinks::index();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function workspaceEntitlementSummary(): array
|
||||
{
|
||||
return app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function workspaceCommercialLifecycleSummary(): array
|
||||
{
|
||||
return app(WorkspaceCommercialLifecycleResolver::class)->summary($this->workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('change_commercial_state')
|
||||
->label('Change commercial state')
|
||||
->icon('heroicon-o-adjustments-horizontal')
|
||||
->color('warning')
|
||||
->visible(fn (): bool => $this->canManageCommercialLifecycle())
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Change commercial state')
|
||||
->modalDescription('This changes the workspace commercial lifecycle overlay. The rationale is audited and affects onboarding activation and review-pack starts.')
|
||||
->form([
|
||||
Select::make('state')
|
||||
->label('Commercial state')
|
||||
->options(WorkspaceCommercialLifecycleResolver::stateLabels())
|
||||
->required()
|
||||
->default(fn (): string => (string) ($this->workspaceCommercialLifecycleSummary()['state'] ?? WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID)),
|
||||
Textarea::make('reason')
|
||||
->label('Rationale')
|
||||
->required()
|
||||
->minLength(5)
|
||||
->maxLength(500)
|
||||
->rows(4),
|
||||
])
|
||||
->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->updateWorkspaceCommercialLifecycle(
|
||||
actor: $actor,
|
||||
workspace: $this->workspace,
|
||||
state: (string) ($data['state'] ?? ''),
|
||||
reason: (string) ($data['reason'] ?? ''),
|
||||
);
|
||||
|
||||
$this->workspace->refresh();
|
||||
|
||||
Notification::make()
|
||||
->title('Commercial state updated')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private function canManageCommercialLifecycle(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* overall: array{label: string, color: string, icon: string|null},
|
||||
* reason: string,
|
||||
* impact: string,
|
||||
* recommended_action: string,
|
||||
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
|
||||
* window_label: string
|
||||
* }|null
|
||||
*/
|
||||
public function customerHealthDecision(): ?array
|
||||
{
|
||||
$window = SystemConsoleWindow::fromNullable(request()->query('window'));
|
||||
$summary = app(WorkspaceHealthSummaryQuery::class)->summaryForWorkspace($this->workspace, $window);
|
||||
|
||||
if (! is_array($summary)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildCustomerHealthDecision($summary, $window, 'workspace');
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,688 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Ops;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationalControlActivation;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\AuditRecorder;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Audit\AuditActorSnapshot;
|
||||
use App\Support\Audit\AuditTargetSnapshot;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OperationalControls\OperationalControlCatalog;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterface;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Radio;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class Controls extends Page
|
||||
{
|
||||
protected static ?string $navigationLabel = 'Controls';
|
||||
|
||||
protected static ?string $title = 'Operational Controls';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-pause-circle';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
|
||||
|
||||
protected static ?string $slug = 'ops/controls';
|
||||
|
||||
protected string $view = 'filament.system.pages.ops.controls';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasCapability(PlatformCapabilities::ACCESS_SYSTEM_PANEL)
|
||||
&& $user->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE);
|
||||
}
|
||||
|
||||
public function getHeader(): ?View
|
||||
{
|
||||
return view('filament.system.pages.ops.partials.controls-header', [
|
||||
'breadcrumbs' => filament()->hasBreadcrumbs() ? $this->getBreadcrumbs() : [],
|
||||
'heading' => $this->getHeading(),
|
||||
'subheading' => $this->getSubheading(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$this->pauseRestoreExecuteAction(),
|
||||
$this->resumeRestoreExecuteAction(),
|
||||
$this->viewHistoryRestoreExecuteAction(),
|
||||
$this->pauseAiExecutionAction(),
|
||||
$this->resumeAiExecutionAction(),
|
||||
$this->viewHistoryAiExecutionAction(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function controlCards(): array
|
||||
{
|
||||
$catalog = app(OperationalControlCatalog::class);
|
||||
|
||||
return array_map(
|
||||
fn (string $controlKey): array => $this->controlSummary($controlKey),
|
||||
$catalog->keys(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function controlSummary(string $controlKey): array
|
||||
{
|
||||
$definition = app(OperationalControlCatalog::class)->definition($controlKey);
|
||||
$activations = $this->activeActivationsForControl($controlKey);
|
||||
|
||||
$effectiveState = $activations->isEmpty() ? 'enabled' : 'paused';
|
||||
$stateLabel = match (true) {
|
||||
$activations->contains(fn (OperationalControlActivation $activation): bool => $activation->scope_type === 'global') => 'Paused globally',
|
||||
$activations->isNotEmpty() => sprintf('Workspace pauses active (%d)', $activations->where('scope_type', 'workspace')->count()),
|
||||
default => 'Enabled',
|
||||
};
|
||||
|
||||
return [
|
||||
'control_key' => $controlKey,
|
||||
'action_slug' => $this->actionSlug($controlKey),
|
||||
'label' => (string) $definition['label'],
|
||||
'effective_state' => $effectiveState,
|
||||
'state_label' => $stateLabel,
|
||||
'supported_scopes' => $definition['supported_scopes'],
|
||||
'affected_surfaces' => $definition['affected_surfaces'],
|
||||
'active_activations' => $activations
|
||||
->map(fn (OperationalControlActivation $activation): array => $this->activationSummary($activation))
|
||||
->values()
|
||||
->all(),
|
||||
'history_count' => $this->recentAuditEventsForControl($controlKey)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{control_key: string, scope_type: string, workspace_id: ?int, workspace_count: int, tenant_count: int, summary: string}
|
||||
*/
|
||||
public function scopeImpactPreview(string $controlKey, string $scopeType, ?int $workspaceId): array
|
||||
{
|
||||
$label = app(OperationalControlCatalog::class)->label($controlKey);
|
||||
|
||||
if ($scopeType === 'workspace') {
|
||||
$workspace = is_int($workspaceId)
|
||||
? Workspace::query()->whereKey($workspaceId)->first()
|
||||
: null;
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return [
|
||||
'control_key' => $controlKey,
|
||||
'scope_type' => $scopeType,
|
||||
'workspace_id' => null,
|
||||
'workspace_count' => 0,
|
||||
'tenant_count' => 0,
|
||||
'summary' => 'Select a workspace to preview the scope impact.',
|
||||
];
|
||||
}
|
||||
|
||||
$tenantCount = Tenant::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('external_id', '!=', 'platform')
|
||||
->count();
|
||||
|
||||
return [
|
||||
'control_key' => $controlKey,
|
||||
'scope_type' => $scopeType,
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'workspace_count' => 1,
|
||||
'tenant_count' => $tenantCount,
|
||||
'summary' => sprintf('%s will affect workspace %s and %d %s.', $label, $workspace->name, $tenantCount, $tenantCount === 1 ? 'tenant' : 'tenants'),
|
||||
];
|
||||
}
|
||||
|
||||
$tenantCount = Tenant::query()
|
||||
->where('external_id', '!=', 'platform')
|
||||
->count();
|
||||
|
||||
$workspaceCount = Tenant::query()
|
||||
->where('external_id', '!=', 'platform')
|
||||
->distinct('workspace_id')
|
||||
->count('workspace_id');
|
||||
|
||||
return [
|
||||
'control_key' => $controlKey,
|
||||
'scope_type' => 'global',
|
||||
'workspace_id' => null,
|
||||
'workspace_count' => $workspaceCount,
|
||||
'tenant_count' => $tenantCount,
|
||||
'summary' => sprintf('%s will affect %d %s across %d %s.', $label, $workspaceCount, $workspaceCount === 1 ? 'workspace' : 'workspaces', $tenantCount, $tenantCount === 1 ? 'tenant' : 'tenants'),
|
||||
];
|
||||
}
|
||||
|
||||
public function pauseRestoreExecuteAction(): Action
|
||||
{
|
||||
return $this->pauseActionFor('restore.execute');
|
||||
}
|
||||
|
||||
public function resumeRestoreExecuteAction(): Action
|
||||
{
|
||||
return $this->resumeActionFor('restore.execute');
|
||||
}
|
||||
|
||||
public function viewHistoryRestoreExecuteAction(): Action
|
||||
{
|
||||
return $this->historyActionFor('restore.execute');
|
||||
}
|
||||
|
||||
public function pauseAiExecutionAction(): Action
|
||||
{
|
||||
return $this->pauseActionFor('ai.execution');
|
||||
}
|
||||
|
||||
public function resumeAiExecutionAction(): Action
|
||||
{
|
||||
return $this->resumeActionFor('ai.execution');
|
||||
}
|
||||
|
||||
public function viewHistoryAiExecutionAction(): Action
|
||||
{
|
||||
return $this->historyActionFor('ai.execution');
|
||||
}
|
||||
|
||||
private function pauseActionFor(string $controlKey): Action
|
||||
{
|
||||
$label = app(OperationalControlCatalog::class)->label($controlKey);
|
||||
|
||||
return Action::make('pause_'.$this->actionSlug($controlKey))
|
||||
->label('Pause '.$label)
|
||||
->icon('heroicon-o-pause')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Pause '.$label)
|
||||
->modalDescription('Review the scope impact, reason, and optional expiry before confirming this control change.')
|
||||
->form($this->pauseFormSchema($controlKey))
|
||||
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
|
||||
$actor = $this->controlsActor();
|
||||
[$scopeType, $workspace, $reasonText, $expiresAt] = $this->normalizePauseInput($controlKey, $data);
|
||||
|
||||
$scopeQuery = $this->activationScopeQuery($controlKey, $scopeType, $workspace);
|
||||
|
||||
(clone $scopeQuery)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now())
|
||||
->delete();
|
||||
|
||||
$activation = (clone $scopeQuery)->notExpired()->first();
|
||||
$auditAction = $activation instanceof OperationalControlActivation
|
||||
? AuditActionId::OperationalControlUpdated
|
||||
: AuditActionId::OperationalControlPaused;
|
||||
|
||||
if ($activation instanceof OperationalControlActivation) {
|
||||
$activation->fill([
|
||||
'reason_text' => $reasonText,
|
||||
'expires_at' => $expiresAt,
|
||||
'updated_by_platform_user_id' => (int) $actor->getKey(),
|
||||
])->save();
|
||||
} else {
|
||||
$activation = OperationalControlActivation::query()->create([
|
||||
'control_key' => $controlKey,
|
||||
'scope_type' => $scopeType,
|
||||
'workspace_id' => $workspace instanceof Workspace ? (int) $workspace->getKey() : null,
|
||||
'reason_text' => $reasonText,
|
||||
'expires_at' => $expiresAt,
|
||||
'created_by_platform_user_id' => (int) $actor->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->recordControlMutation(
|
||||
auditAction: $auditAction,
|
||||
activation: $activation,
|
||||
actor: $actor,
|
||||
auditRecorder: $auditRecorder,
|
||||
workspaceAuditLogger: $workspaceAuditLogger,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title(sprintf('%s %s', $label, $auditAction === AuditActionId::OperationalControlPaused ? 'paused' : 'updated'))
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
private function resumeActionFor(string $controlKey): Action
|
||||
{
|
||||
$label = app(OperationalControlCatalog::class)->label($controlKey);
|
||||
|
||||
return Action::make('resume_'.$this->actionSlug($controlKey))
|
||||
->label('Resume '.$label)
|
||||
->icon('heroicon-o-play')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Resume '.$label)
|
||||
->modalDescription('Remove the selected pause so new starts can proceed again.')
|
||||
->form($this->resumeFormSchema($controlKey))
|
||||
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
|
||||
$actor = $this->controlsActor();
|
||||
[$scopeType, $workspace] = $this->normalizeResumeInput($controlKey, $data);
|
||||
|
||||
$activation = $this->activationScopeQuery($controlKey, $scopeType, $workspace)
|
||||
->notExpired()
|
||||
->first();
|
||||
|
||||
if (! $activation instanceof OperationalControlActivation) {
|
||||
Notification::make()
|
||||
->title(sprintf('%s already enabled', $label))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$activationSnapshot = $activation->replicate();
|
||||
$activationSnapshot->forceFill($activation->getAttributes());
|
||||
$activation->delete();
|
||||
|
||||
$this->recordControlMutation(
|
||||
auditAction: AuditActionId::OperationalControlResumed,
|
||||
activation: $activationSnapshot,
|
||||
actor: $actor,
|
||||
auditRecorder: $auditRecorder,
|
||||
workspaceAuditLogger: $workspaceAuditLogger,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title($label.' resumed')
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
private function historyActionFor(string $controlKey): Action
|
||||
{
|
||||
$label = app(OperationalControlCatalog::class)->label($controlKey);
|
||||
|
||||
return Action::make('view_history_'.$this->actionSlug($controlKey))
|
||||
->label('View '.$label.' history')
|
||||
->link()
|
||||
->modalHeading($label.' history')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close')
|
||||
->modalContent(fn () => view('filament.system.pages.ops.partials.operational-control-history', [
|
||||
'events' => $this->recentAuditEventsForControl($controlKey),
|
||||
'label' => $label,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
private function pauseFormSchema(string $controlKey): array
|
||||
{
|
||||
return [
|
||||
Radio::make('scope_type')
|
||||
->label('Scope')
|
||||
->options($this->scopeOptions($controlKey))
|
||||
->default($this->defaultScopeFor($controlKey))
|
||||
->live()
|
||||
->required(),
|
||||
|
||||
Select::make('workspace_id')
|
||||
->label('Workspace')
|
||||
->searchable()
|
||||
->visible(fn (callable $get): bool => $get('scope_type') === 'workspace')
|
||||
->required(fn (callable $get): bool => $get('scope_type') === 'workspace')
|
||||
->live()
|
||||
->getSearchResultsUsing(function (string $search): array {
|
||||
return Workspace::query()
|
||||
->where('name', 'like', "%{$search}%")
|
||||
->orderBy('name')
|
||||
->limit(25)
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
})
|
||||
->getOptionLabelUsing(function ($value): ?string {
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Workspace::query()->whereKey((int) $value)->value('name');
|
||||
}),
|
||||
|
||||
Textarea::make('reason_text')
|
||||
->label('Reason')
|
||||
->required()
|
||||
->minLength(5)
|
||||
->maxLength(500)
|
||||
->rows(4),
|
||||
|
||||
DateTimePicker::make('expires_at')
|
||||
->label('Expires at')
|
||||
->seconds(false)
|
||||
->nullable(),
|
||||
|
||||
Placeholder::make('scope_preview')
|
||||
->label('Scope impact preview')
|
||||
->content(function (callable $get) use ($controlKey): string {
|
||||
$preview = $this->scopeImpactPreview(
|
||||
$controlKey,
|
||||
(string) ($get('scope_type') ?? 'global'),
|
||||
is_numeric($get('workspace_id')) ? (int) $get('workspace_id') : null,
|
||||
);
|
||||
|
||||
return (string) $preview['summary'];
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
private function resumeFormSchema(string $controlKey): array
|
||||
{
|
||||
return [
|
||||
Radio::make('scope_type')
|
||||
->label('Scope')
|
||||
->options($this->scopeOptions($controlKey))
|
||||
->default($this->defaultScopeFor($controlKey))
|
||||
->live()
|
||||
->required(),
|
||||
|
||||
Select::make('workspace_id')
|
||||
->label('Workspace')
|
||||
->searchable()
|
||||
->visible(fn (callable $get): bool => $get('scope_type') === 'workspace')
|
||||
->required(fn (callable $get): bool => $get('scope_type') === 'workspace')
|
||||
->getSearchResultsUsing(function (string $search): array {
|
||||
return Workspace::query()
|
||||
->where('name', 'like', "%{$search}%")
|
||||
->orderBy('name')
|
||||
->limit(25)
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
})
|
||||
->getOptionLabelUsing(function ($value): ?string {
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Workspace::query()->whereKey((int) $value)->value('name');
|
||||
}),
|
||||
|
||||
Placeholder::make('scope_preview')
|
||||
->label('Resume impact preview')
|
||||
->content(function (callable $get) use ($controlKey): string {
|
||||
$preview = $this->scopeImpactPreview(
|
||||
$controlKey,
|
||||
(string) ($get('scope_type') ?? 'global'),
|
||||
is_numeric($get('workspace_id')) ? (int) $get('workspace_id') : null,
|
||||
);
|
||||
|
||||
return (string) $preview['summary'];
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private function controlsActor(): PlatformUser
|
||||
{
|
||||
$actor = auth('platform')->user();
|
||||
|
||||
if (! $actor instanceof PlatformUser) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $actor->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: ?Workspace, 2: string, 3: ?CarbonInterface}
|
||||
*/
|
||||
private function normalizePauseInput(string $controlKey, array $data): array
|
||||
{
|
||||
[$scopeType, $workspace] = $this->resolveScopeInput($controlKey, $data);
|
||||
$reasonText = trim((string) ($data['reason_text'] ?? ''));
|
||||
|
||||
if ($reasonText === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'reason_text' => 'A reason is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
$expiresAt = null;
|
||||
|
||||
if (filled($data['expires_at'] ?? null)) {
|
||||
$expiresAt = Carbon::parse((string) $data['expires_at']);
|
||||
|
||||
if ($expiresAt->lessThanOrEqualTo(now())) {
|
||||
throw ValidationException::withMessages([
|
||||
'expires_at' => 'Expiry must be in the future.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [$scopeType, $workspace, $reasonText, $expiresAt];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: ?Workspace}
|
||||
*/
|
||||
private function normalizeResumeInput(string $controlKey, array $data): array
|
||||
{
|
||||
return $this->resolveScopeInput($controlKey, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: ?Workspace}
|
||||
*/
|
||||
private function resolveScopeInput(string $controlKey, array $data): array
|
||||
{
|
||||
$scopeType = (string) ($data['scope_type'] ?? 'global');
|
||||
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'] ?? ['global'];
|
||||
|
||||
if (! in_array($scopeType, $supportedScopes, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'scope_type' => 'Invalid scope selected.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($scopeType === 'global') {
|
||||
return [$scopeType, null];
|
||||
}
|
||||
|
||||
$workspaceId = $data['workspace_id'] ?? null;
|
||||
|
||||
if (! is_numeric($workspaceId)) {
|
||||
throw ValidationException::withMessages([
|
||||
'workspace_id' => 'A workspace is required for workspace scope.',
|
||||
]);
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey((int) $workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw ValidationException::withMessages([
|
||||
'workspace_id' => 'The selected workspace could not be found.',
|
||||
]);
|
||||
}
|
||||
|
||||
return [$scopeType, $workspace];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function scopeOptions(string $controlKey): array
|
||||
{
|
||||
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
|
||||
|
||||
return Arr::only([
|
||||
'global' => 'Global',
|
||||
'workspace' => 'One workspace',
|
||||
], $supportedScopes);
|
||||
}
|
||||
|
||||
private function defaultScopeFor(string $controlKey): string
|
||||
{
|
||||
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
|
||||
|
||||
return $supportedScopes[0] ?? 'global';
|
||||
}
|
||||
|
||||
private function activationScopeQuery(string $controlKey, string $scopeType, ?Workspace $workspace): \Illuminate\Database\Eloquent\Builder
|
||||
{
|
||||
$query = OperationalControlActivation::query()
|
||||
->forControl($controlKey)
|
||||
->where('scope_type', $scopeType);
|
||||
|
||||
if ($scopeType === 'workspace') {
|
||||
$query->where('workspace_id', (int) $workspace?->getKey());
|
||||
} else {
|
||||
$query->whereNull('workspace_id');
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function recordControlMutation(
|
||||
AuditActionId $auditAction,
|
||||
OperationalControlActivation $activation,
|
||||
PlatformUser $actor,
|
||||
AuditRecorder $auditRecorder,
|
||||
WorkspaceAuditLogger $workspaceAuditLogger,
|
||||
): void {
|
||||
$label = app(OperationalControlCatalog::class)->label((string) $activation->control_key);
|
||||
$summary = sprintf('%s %s', $label, match ($auditAction) {
|
||||
AuditActionId::OperationalControlPaused => 'paused',
|
||||
AuditActionId::OperationalControlUpdated => 'updated',
|
||||
AuditActionId::OperationalControlResumed => 'resumed',
|
||||
default => 'changed',
|
||||
});
|
||||
|
||||
$metadata = array_filter([
|
||||
'control_key' => (string) $activation->control_key,
|
||||
'scope_type' => (string) $activation->scope_type,
|
||||
'workspace_id' => is_numeric($activation->workspace_id) ? (int) $activation->workspace_id : null,
|
||||
'reason_text' => $activation->reason_text,
|
||||
'expires_at' => $activation->expires_at?->toIso8601String(),
|
||||
'actor_id' => (int) $actor->getKey(),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
|
||||
if ((string) $activation->scope_type === 'global') {
|
||||
$auditRecorder->record(
|
||||
action: $auditAction,
|
||||
context: ['metadata' => $metadata],
|
||||
actor: AuditActorSnapshot::platform($actor),
|
||||
target: new AuditTargetSnapshot(
|
||||
type: 'operational_control',
|
||||
id: (string) $activation->getKey(),
|
||||
label: $label,
|
||||
),
|
||||
outcome: 'success',
|
||||
summary: $summary,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey((int) $activation->workspace_id)->firstOrFail();
|
||||
|
||||
$workspaceAuditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: $auditAction,
|
||||
context: ['metadata' => $metadata],
|
||||
actor: $actor,
|
||||
status: 'success',
|
||||
resourceType: 'operational_control',
|
||||
resourceId: (string) $activation->getKey(),
|
||||
targetLabel: $label,
|
||||
summary: $summary,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, OperationalControlActivation>
|
||||
*/
|
||||
private function activeActivationsForControl(string $controlKey): Collection
|
||||
{
|
||||
return OperationalControlActivation::query()
|
||||
->forControl($controlKey)
|
||||
->notExpired()
|
||||
->with(['workspace', 'createdBy', 'updatedBy'])
|
||||
->orderByRaw("CASE WHEN scope_type = 'global' THEN 0 ELSE 1 END")
|
||||
->orderBy('workspace_id')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function activationSummary(OperationalControlActivation $activation): array
|
||||
{
|
||||
$owner = $activation->updatedBy ?? $activation->createdBy;
|
||||
$workspaceName = $activation->workspace?->name;
|
||||
|
||||
return [
|
||||
'id' => (int) $activation->getKey(),
|
||||
'scope_type' => (string) $activation->scope_type,
|
||||
'scope_label' => (string) $activation->scope_type === 'global'
|
||||
? 'Global'
|
||||
: sprintf('Workspace: %s', $workspaceName ?? '#'.(int) $activation->workspace_id),
|
||||
'workspace_id' => is_numeric($activation->workspace_id) ? (int) $activation->workspace_id : null,
|
||||
'workspace_name' => $workspaceName,
|
||||
'reason_text' => (string) $activation->reason_text,
|
||||
'expires_at' => $activation->expires_at?->toIso8601String(),
|
||||
'expires_label' => $activation->expires_at?->diffForHumans() ?? 'No expiry',
|
||||
'owner_name' => $owner?->name ?: $owner?->email ?: 'Unknown operator',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, AuditLog>
|
||||
*/
|
||||
private function recentAuditEventsForControl(string $controlKey): Collection
|
||||
{
|
||||
return AuditLog::query()
|
||||
->where('metadata->control_key', $controlKey)
|
||||
->whereIn('action', [
|
||||
AuditActionId::OperationalControlPaused->value,
|
||||
AuditActionId::OperationalControlUpdated->value,
|
||||
AuditActionId::OperationalControlResumed->value,
|
||||
AuditActionId::OperationalControlExecutionBlocked->value,
|
||||
])
|
||||
->latestFirst()
|
||||
->limit(10)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function actionSlug(string $controlKey): string
|
||||
{
|
||||
return str_replace('.', '_', $controlKey);
|
||||
}
|
||||
}
|
||||
@ -4,9 +4,25 @@
|
||||
|
||||
namespace App\Filament\System\Pages\Ops;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\BreakGlassSession;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Services\Runbooks\RunbookReason;
|
||||
use App\Services\System\AllowedTenantUniverse;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Radio;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class Runbooks extends Page
|
||||
{
|
||||
@ -20,6 +36,53 @@ class Runbooks extends Page
|
||||
|
||||
protected string $view = 'filament.system.pages.ops.runbooks';
|
||||
|
||||
public string $findingsScopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;
|
||||
|
||||
public ?int $findingsTenantId = null;
|
||||
|
||||
public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;
|
||||
|
||||
public ?int $tenantId = null;
|
||||
|
||||
/**
|
||||
* @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null
|
||||
*/
|
||||
public ?array $findingsPreflight = null;
|
||||
|
||||
/**
|
||||
* @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null
|
||||
*/
|
||||
public ?array $preflight = null;
|
||||
|
||||
public function findingsScopeLabel(): string
|
||||
{
|
||||
if ($this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) {
|
||||
return 'All tenants';
|
||||
}
|
||||
|
||||
$tenantName = $this->selectedTenantName($this->findingsTenantId);
|
||||
|
||||
if ($tenantName !== null) {
|
||||
return "Single tenant ({$tenantName})";
|
||||
}
|
||||
|
||||
return $this->findingsTenantId !== null ? "Single tenant (#{$this->findingsTenantId})" : 'Single tenant';
|
||||
}
|
||||
|
||||
public function findingsLastRun(): ?OperationRun
|
||||
{
|
||||
return $this->lastRunForType(FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY);
|
||||
}
|
||||
|
||||
public function selectedTenantName(?int $tenantId): ?string
|
||||
{
|
||||
if ($tenantId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Tenant::query()->whereKey($tenantId)->value('name');
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
@ -31,4 +94,221 @@ public static function canAccess(): bool
|
||||
return $user->hasCapability(PlatformCapabilities::OPS_VIEW)
|
||||
&& $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('preflight')
|
||||
->label('Preflight')
|
||||
->color('gray')
|
||||
->icon('heroicon-o-magnifying-glass')
|
||||
->form($this->findingsScopeForm())
|
||||
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
|
||||
$scope = $this->trustedFindingsScopeFromFormData($data, app(AllowedTenantUniverse::class));
|
||||
|
||||
$this->findingsScopeMode = $scope->mode;
|
||||
$this->findingsTenantId = $scope->tenantId;
|
||||
$this->scopeMode = $scope->mode;
|
||||
$this->tenantId = $scope->tenantId;
|
||||
|
||||
$this->findingsPreflight = $runbookService->preflight($scope);
|
||||
$this->preflight = $this->findingsPreflight;
|
||||
|
||||
Notification::make()
|
||||
->title('Preflight complete')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('run')
|
||||
->label('Run…')
|
||||
->icon('heroicon-o-play')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Run: Rebuild Findings Lifecycle')
|
||||
->modalDescription('This operation may modify customer data. Review the preflight and confirm before running.')
|
||||
->form($this->findingsRunForm())
|
||||
->disabled(fn (): bool => ! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0)
|
||||
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
|
||||
if (! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'preflight' => 'Run preflight first.',
|
||||
]);
|
||||
}
|
||||
|
||||
$scope = $this->trustedFindingsScopeFromState(app(AllowedTenantUniverse::class));
|
||||
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->hasCapability(PlatformCapabilities::RUNBOOKS_RUN)
|
||||
|| ! $user->hasCapability(PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL)
|
||||
) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($scope->isAllTenants()) {
|
||||
$typedConfirmation = (string) ($data['typed_confirmation'] ?? '');
|
||||
|
||||
if ($typedConfirmation !== 'BACKFILL') {
|
||||
throw ValidationException::withMessages([
|
||||
'typed_confirmation' => 'Please type BACKFILL to confirm.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$reason = RunbookReason::fromNullableArray([
|
||||
'reason_code' => $data['reason_code'] ?? null,
|
||||
'reason_text' => $data['reason_text'] ?? null,
|
||||
]);
|
||||
|
||||
$run = $runbookService->start(
|
||||
scope: $scope,
|
||||
initiator: $user,
|
||||
reason: $reason,
|
||||
source: 'system_ui',
|
||||
);
|
||||
|
||||
$viewUrl = SystemOperationRunLinks::view($run);
|
||||
|
||||
$toast = $run->wasRecentlyCreated
|
||||
? OperationUxPresenter::queuedToast((string) $run->type)->body('The runbook will execute in the background.')
|
||||
: OperationUxPresenter::alreadyQueuedToast((string) $run->type);
|
||||
|
||||
$toast
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($viewUrl),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
private function findingsScopeForm(): array
|
||||
{
|
||||
return [
|
||||
Radio::make('scope_mode')
|
||||
->label('Scope')
|
||||
->options([
|
||||
FindingsLifecycleBackfillScope::MODE_ALL_TENANTS => 'All tenants',
|
||||
FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT => 'Single tenant',
|
||||
])
|
||||
->default($this->findingsScopeMode)
|
||||
->live()
|
||||
->required(),
|
||||
|
||||
Select::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->searchable()
|
||||
->visible(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
|
||||
->required(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
|
||||
->getSearchResultsUsing(function (string $search, AllowedTenantUniverse $universe): array {
|
||||
return $universe
|
||||
->query()
|
||||
->where('name', 'like', "%{$search}%")
|
||||
->orderBy('name')
|
||||
->limit(25)
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
})
|
||||
->getOptionLabelUsing(function ($value, AllowedTenantUniverse $universe): ?string {
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $universe
|
||||
->query()
|
||||
->whereKey((int) $value)
|
||||
->value('name');
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
private function findingsRunForm(): array
|
||||
{
|
||||
return [
|
||||
TextInput::make('typed_confirmation')
|
||||
->label('Type BACKFILL to confirm')
|
||||
->visible(fn (): bool => $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
|
||||
->required(fn (): bool => $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
|
||||
->in(['BACKFILL'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type BACKFILL to confirm.',
|
||||
]),
|
||||
|
||||
Select::make('reason_code')
|
||||
->label('Reason code')
|
||||
->options(RunbookReason::options())
|
||||
->required(function (BreakGlassSession $breakGlass): bool {
|
||||
return $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
|
||||
}),
|
||||
|
||||
Textarea::make('reason_text')
|
||||
->label('Reason')
|
||||
->rows(4)
|
||||
->maxLength(500)
|
||||
->required(function (BreakGlassSession $breakGlass): bool {
|
||||
return $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private function lastRunForType(string $type): ?OperationRun
|
||||
{
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->first();
|
||||
|
||||
if (! $platformTenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OperationRun::query()
|
||||
->where('workspace_id', (int) $platformTenant->workspace_id)
|
||||
->where('type', $type)
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function trustedFindingsScopeFromFormData(array $data, AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
|
||||
{
|
||||
$scope = FindingsLifecycleBackfillScope::fromArray([
|
||||
'mode' => $data['scope_mode'] ?? null,
|
||||
'tenant_id' => $data['tenant_id'] ?? null,
|
||||
]);
|
||||
|
||||
if (! $scope->isSingleTenant()) {
|
||||
return $scope;
|
||||
}
|
||||
|
||||
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($scope->tenantId);
|
||||
|
||||
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
|
||||
}
|
||||
|
||||
private function trustedFindingsScopeFromState(AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
|
||||
{
|
||||
if ($this->findingsScopeMode !== FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) {
|
||||
return FindingsLifecycleBackfillScope::allTenants();
|
||||
}
|
||||
|
||||
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($this->findingsTenantId);
|
||||
|
||||
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,14 +19,12 @@ class ControlTowerKpis extends StatsOverviewWidget
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public ?string $window = null;
|
||||
|
||||
/**
|
||||
* @return array<Stat>
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
|
||||
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
||||
$start = $window->startAt();
|
||||
|
||||
$baseQuery = OperationRun::query()->where('created_at', '>=', $start);
|
||||
|
||||
@ -21,14 +21,12 @@ class ControlTowerRecentFailures extends Widget
|
||||
|
||||
protected string $view = 'filament.system.widgets.control-tower-recent-failures';
|
||||
|
||||
public ?string $window = null;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
|
||||
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
||||
$start = $window->startAt();
|
||||
|
||||
/** @var Collection<int, OperationRun> $runs */
|
||||
|
||||
@ -23,14 +23,12 @@ class ControlTowerTopOffenders extends Widget
|
||||
|
||||
protected string $view = 'filament.system.widgets.control-tower-top-offenders';
|
||||
|
||||
public ?string $window = null;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
|
||||
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
||||
$start = $window->startAt();
|
||||
|
||||
/** @var Collection<int, OperationRun> $grouped */
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Widgets;
|
||||
|
||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class CustomerHealthKpis extends StatsOverviewWidget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected ?string $heading = 'Customer health';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public ?string $window = null;
|
||||
|
||||
/**
|
||||
* @return array<Stat>
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
|
||||
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
|
||||
$counts = app(WorkspaceHealthSummaryQuery::class)->healthCounts($window);
|
||||
|
||||
return [
|
||||
Stat::make('Healthy', $counts['ok'])
|
||||
->description(sprintf('Operational stability, review-pack readiness, and engagement freshness honor %s.', $windowLabel))
|
||||
->color($counts['ok'] > 0 ? 'success' : 'gray'),
|
||||
Stat::make('Warning', $counts['warn'])
|
||||
->description('Onboarding readiness, provider health, and governance pressure stay point-in-time.')
|
||||
->color($counts['warn'] > 0 ? 'warning' : 'gray'),
|
||||
Stat::make('Critical', $counts['critical'])
|
||||
->description('Overall workspace health is derived from existing system truth only.')
|
||||
->color($counts['critical'] > 0 ? 'danger' : 'gray'),
|
||||
Stat::make('Unknown', $counts['unknown'])
|
||||
->description('Missing or stale inputs stay explicit instead of silently reading healthy.')
|
||||
->color('gray'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,137 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Widgets;
|
||||
|
||||
use App\Models\PlatformUser;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class CustomerHealthTopWorkspaces extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected string $view = 'filament.system.widgets.customer-health-top-workspaces';
|
||||
|
||||
public ?string $window = null;
|
||||
|
||||
public static function canView(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! static::canOpenRuns($user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
||||
|
||||
return app(WorkspaceHealthSummaryQuery::class)
|
||||
->attentionNeeded($window, 10)
|
||||
->contains(fn (array $summary): bool => static::canAccessNextLink($summary['next_link'], $user));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
|
||||
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return [
|
||||
'windowLabel' => $windowLabel,
|
||||
'rows' => app(WorkspaceHealthSummaryQuery::class)
|
||||
->attentionNeeded($window, 10)
|
||||
->filter(fn (array $summary): bool => $user instanceof PlatformUser && static::canAccessNextLink($summary['next_link'], $user))
|
||||
->map(fn (array $summary): array => $this->presentSummary($summary)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{label: string, url: string} $nextLink
|
||||
*/
|
||||
private static function canAccessNextLink(array $nextLink, PlatformUser $user): bool
|
||||
{
|
||||
if ($user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return static::canOpenRuns($user)
|
||||
&& $nextLink['url'] === SystemOperationRunLinks::index();
|
||||
}
|
||||
|
||||
private static function canOpenRuns(PlatformUser $user): bool
|
||||
{
|
||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* workspace_id: int,
|
||||
* workspace_name: string,
|
||||
* overall_level: string,
|
||||
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
|
||||
* dominant_dimension_keys: list<string>,
|
||||
* non_ok_dimension_count: int,
|
||||
* next_link: array{label: string, url: string}
|
||||
* } $summary
|
||||
* @return array{
|
||||
* workspace_id: int,
|
||||
* workspace_label: string,
|
||||
* overall: array{label: string, color: string, icon: ?string},
|
||||
* dominant_copy: string,
|
||||
* dominant_dimensions: list<array{label: string, color: string, icon: ?string}>,
|
||||
* next_link: array{label: string, url: string}
|
||||
* }
|
||||
*/
|
||||
private function presentSummary(array $summary): array
|
||||
{
|
||||
$dominantDimensions = collect($summary['dominant_dimension_keys'])
|
||||
->take(2)
|
||||
->map(function (string $dimensionKey) use ($summary): array {
|
||||
$dimension = $summary['dimensions'][$dimensionKey];
|
||||
$badge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $dimension['level']);
|
||||
|
||||
return [
|
||||
'label' => $dimension['label'],
|
||||
'color' => $badge->color,
|
||||
'icon' => $badge->icon,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$overallBadge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $summary['overall_level']);
|
||||
|
||||
return [
|
||||
'workspace_id' => $summary['workspace_id'],
|
||||
'workspace_label' => $summary['workspace_name'],
|
||||
'overall' => [
|
||||
'label' => $overallBadge->label,
|
||||
'color' => $overallBadge->color,
|
||||
'icon' => $overallBadge->icon,
|
||||
],
|
||||
'dominant_copy' => implode(', ', array_column($dominantDimensions, 'label')),
|
||||
'dominant_dimensions' => $dominantDimensions,
|
||||
'next_link' => $summary['next_link'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Widgets;
|
||||
|
||||
use App\Support\ProductTelemetry\ProductTelemetrySummaryQuery;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class ProductTelemetryKpis extends StatsOverviewWidget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected ?string $heading = 'Product telemetry';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public ?string $window = null;
|
||||
|
||||
/**
|
||||
* @return array<Stat>
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
|
||||
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
|
||||
$summary = app(ProductTelemetrySummaryQuery::class)->summarize($window->startAt(), now());
|
||||
|
||||
$stats = [
|
||||
Stat::make('Active workspaces', $summary['active_workspaces'])
|
||||
->description($summary['total_events'] > 0
|
||||
? sprintf('%d events in %s', $summary['total_events'], $windowLabel)
|
||||
: sprintf('No telemetry recorded in %s.', $windowLabel))
|
||||
->color($summary['active_workspaces'] > 0 ? 'primary' : 'gray'),
|
||||
];
|
||||
|
||||
foreach ($summary['families'] as $family) {
|
||||
$stats[] = Stat::make($family['label'], $family['count'])
|
||||
->description($windowLabel)
|
||||
->color($family['count'] > 0 ? 'primary' : 'gray');
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
@ -14,9 +14,7 @@
|
||||
use App\Support\Inventory\InventoryKpiBadges;
|
||||
use App\Support\Inventory\TenantCoverageTruth;
|
||||
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
@ -58,7 +56,7 @@ protected function getStats(): array
|
||||
|
||||
$inventoryOps = (int) OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value))
|
||||
->where('type', 'inventory_sync')
|
||||
->active()
|
||||
->count();
|
||||
|
||||
|
||||
@ -4,8 +4,6 @@
|
||||
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
@ -20,7 +18,6 @@
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class TenantReviewPackCard extends Widget
|
||||
@ -69,26 +66,6 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
$decision = $service->reviewPackGenerationDecisionForTenant($tenant);
|
||||
|
||||
if ((bool) ($decision['is_blocked'] ?? false)) {
|
||||
Notification::make()
|
||||
->title('Review pack generation unavailable')
|
||||
->body((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ((bool) ($decision['is_warning'] ?? false) && is_string($decision['warning_reason'] ?? null)) {
|
||||
Notification::make()
|
||||
->title('Review pack generation allowed with warning')
|
||||
->body($decision['warning_reason'])
|
||||
->warning()
|
||||
->send();
|
||||
}
|
||||
|
||||
$activeRun = $service->checkActiveRun($tenant)
|
||||
? OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
@ -113,20 +90,10 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$reviewPack = $service->generate($tenant, $user, [
|
||||
'include_pii' => $includePii,
|
||||
'include_operations' => $includeOperations,
|
||||
]);
|
||||
} catch (WorkspaceEntitlementBlockedException $exception) {
|
||||
Notification::make()
|
||||
->title('Review pack generation unavailable')
|
||||
->body($exception->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
$reviewPack = $service->generate($tenant, $user, [
|
||||
'include_pii' => $includePii,
|
||||
'include_operations' => $includeOperations,
|
||||
]);
|
||||
|
||||
$runUrl = $reviewPack->operationRun
|
||||
? OperationRunLinks::tenantlessView($reviewPack->operationRun)
|
||||
@ -163,17 +130,6 @@ protected function getViewData(): array
|
||||
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
|
||||
$canView = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
|
||||
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
||||
$service = app(ReviewPackService::class);
|
||||
$generationEntitlement = $canManage
|
||||
? $service->reviewPackGenerationDecisionForTenant($tenant)
|
||||
: null;
|
||||
$generationBlocked = (bool) ($generationEntitlement['is_blocked'] ?? false);
|
||||
$generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null)
|
||||
? $generationEntitlement['block_reason']
|
||||
: null;
|
||||
$generationWarningReason = is_string($generationEntitlement['warning_reason'] ?? null)
|
||||
? $generationEntitlement['warning_reason']
|
||||
: null;
|
||||
|
||||
$latestPack = ReviewPack::query()
|
||||
->with(['tenantReview', 'operationRun'])
|
||||
@ -190,10 +146,6 @@ protected function getViewData(): array
|
||||
'pollingInterval' => null,
|
||||
'canView' => $canView,
|
||||
'canManage' => $canManage,
|
||||
'generationBlocked' => $generationBlocked,
|
||||
'generationBlockReason' => $generationBlockReason,
|
||||
'generationWarningReason' => $generationWarningReason,
|
||||
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
|
||||
'downloadUrl' => null,
|
||||
'failedReason' => null,
|
||||
'reviewUrl' => null,
|
||||
@ -242,10 +194,6 @@ protected function getViewData(): array
|
||||
'pollingInterval' => self::resolvePollingInterval($latestPack),
|
||||
'canView' => $canView,
|
||||
'canManage' => $canManage,
|
||||
'generationBlocked' => $generationBlocked,
|
||||
'generationBlockReason' => $generationBlockReason,
|
||||
'generationWarningReason' => $generationWarningReason,
|
||||
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
|
||||
'downloadUrl' => $downloadUrl,
|
||||
'failedReason' => $failedReason,
|
||||
'failedReasonDetail' => $failedReasonDetail,
|
||||
@ -276,10 +224,6 @@ private function emptyState(): array
|
||||
'pollingInterval' => null,
|
||||
'canView' => false,
|
||||
'canManage' => false,
|
||||
'generationBlocked' => false,
|
||||
'generationBlockReason' => null,
|
||||
'generationWarningReason' => null,
|
||||
'customerWorkspaceUrl' => null,
|
||||
'downloadUrl' => null,
|
||||
'failedReason' => null,
|
||||
'failedReasonDetail' => null,
|
||||
|
||||
@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Localization\LocaleResolver;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LocalizationController extends Controller
|
||||
{
|
||||
public function context(Request $request, LocaleResolver $resolver): JsonResponse
|
||||
{
|
||||
$plane = $request->query('plane');
|
||||
$context = $request->attributes->get(LocaleResolver::REQUEST_ATTRIBUTE);
|
||||
|
||||
if (is_string($plane) && $plane !== '') {
|
||||
$context = $resolver->resolve($request, $plane);
|
||||
}
|
||||
|
||||
return response()->json(is_array($context) ? $context : $resolver->resolve($request));
|
||||
}
|
||||
|
||||
public function updateOverride(Request $request): RedirectResponse
|
||||
{
|
||||
$locale = LocaleResolver::normalize($request->input('locale'));
|
||||
|
||||
if ($locale === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'locale' => [__('localization.validation.unsupported_locale')],
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put(LocaleResolver::SESSION_OVERRIDE_KEY, $locale);
|
||||
App::setLocale($locale);
|
||||
|
||||
return back()->with('status', __('localization.notifications.locale_override_saved'));
|
||||
}
|
||||
|
||||
public function clearOverride(Request $request, LocaleResolver $resolver): RedirectResponse
|
||||
{
|
||||
$request->session()->forget(LocaleResolver::SESSION_OVERRIDE_KEY);
|
||||
App::setLocale($resolver->resolve($request)['locale']);
|
||||
|
||||
return back()->with('status', __('localization.notifications.locale_override_cleared'));
|
||||
}
|
||||
|
||||
public function updateUserPreference(Request $request, LocaleResolver $resolver): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
abort_unless($user instanceof User, Response::HTTP_NOT_FOUND);
|
||||
|
||||
$rawLocale = $request->input('preferred_locale');
|
||||
$locale = $rawLocale === null || $rawLocale === ''
|
||||
? null
|
||||
: LocaleResolver::normalize($rawLocale);
|
||||
|
||||
if ($rawLocale !== null && $rawLocale !== '' && $locale === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'preferred_locale' => [__('localization.validation.unsupported_locale')],
|
||||
]);
|
||||
}
|
||||
|
||||
$user->forceFill(['preferred_locale' => $locale])->save();
|
||||
$user->refresh();
|
||||
|
||||
App::setLocale($resolver->resolve($request)['locale']);
|
||||
|
||||
return back()->with('status', $locale === null
|
||||
? __('localization.notifications.user_preference_cleared')
|
||||
: __('localization.notifications.user_preference_saved'));
|
||||
}
|
||||
}
|
||||
@ -4,12 +4,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@ -20,21 +15,6 @@ class ReviewPackDownloadController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$tenant = $reviewPack->tenant;
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($reviewPack->status !== ReviewPackStatus::Ready->value) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
@ -49,26 +29,7 @@ public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResp
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::ReviewPackDownloaded,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'review_pack_id' => (int) $reviewPack->getKey(),
|
||||
'tenant_review_id' => $reviewPack->tenant_review_id !== null
|
||||
? (int) $reviewPack->tenant_review_id
|
||||
: null,
|
||||
'source_surface' => (string) $request->query('source_surface', 'review_pack'),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'review_pack',
|
||||
resourceId: (string) $reviewPack->getKey(),
|
||||
targetLabel: sprintf('Review pack #%d', (int) $reviewPack->getKey()),
|
||||
tenant: $tenant,
|
||||
operationRunId: $reviewPack->operation_run_id,
|
||||
);
|
||||
|
||||
$tenant = $reviewPack->tenant;
|
||||
$filename = sprintf(
|
||||
'review-pack-%s-%s.zip',
|
||||
$tenant?->external_id ?? 'unknown',
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Localization\LocaleResolver;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ApplyResolvedLocale
|
||||
{
|
||||
public function __construct(private LocaleResolver $resolver) {}
|
||||
|
||||
public function handle(Request $request, Closure $next, ?string $plane = null): Response
|
||||
{
|
||||
$context = $this->resolver->resolve($request, $plane);
|
||||
|
||||
App::setLocale($context['locale']);
|
||||
Carbon::setLocale($context['locale']);
|
||||
|
||||
$request->attributes->set(LocaleResolver::REQUEST_ATTRIBUTE, $context);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@ -8,10 +8,8 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Collection;
|
||||
@ -38,10 +36,10 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
|
||||
'tenant_id' => (int) $schedule->tenant_id,
|
||||
'user_id' => null,
|
||||
'initiator_name' => 'System',
|
||||
'type' => OperationRunType::BackupScheduleRetention->value,
|
||||
'type' => 'backup_schedule_retention',
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':'.OperationRunType::BackupScheduleRetention->value.':'.$schedule->id.':'.Str::uuid()->toString()),
|
||||
'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':backup_schedule_retention:'.$schedule->id.':'.Str::uuid()->toString()),
|
||||
'context' => [
|
||||
'backup_schedule_id' => (int) $schedule->id,
|
||||
],
|
||||
@ -90,7 +88,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
|
||||
/** @var Collection<int, int> $keepBackupSetIds */
|
||||
$keepBackupSetIds = OperationRun::query()
|
||||
->where('tenant_id', (int) $schedule->tenant_id)
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value))
|
||||
->where('type', 'backup_schedule_run')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('context->backup_schedule_id', (int) $schedule->id)
|
||||
->whereNotNull('context->backup_set_id')
|
||||
@ -105,7 +103,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
|
||||
/** @var Collection<int, int> $allBackupSetIds */
|
||||
$allBackupSetIds = OperationRun::query()
|
||||
->where('tenant_id', (int) $schedule->tenant_id)
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value))
|
||||
->where('type', 'backup_schedule_run')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('context->backup_schedule_id', (int) $schedule->id)
|
||||
->whereNotNull('context->backup_set_id')
|
||||
|
||||
398
apps/platform/app/Jobs/BackfillFindingLifecycleJob.php
Normal file
398
apps/platform/app/Jobs/BackfillFindingLifecycleJob.php
Normal file
@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
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 Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Throwable;
|
||||
|
||||
class BackfillFindingLifecycleJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $tenantId,
|
||||
public readonly int $workspaceId,
|
||||
public readonly ?int $initiatorUserId = null,
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
OperationRunService $operationRuns,
|
||||
FindingSlaPolicy $slaPolicy,
|
||||
FindingsLifecycleBackfillRunbookService $runbookService,
|
||||
): void {
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $this->initiatorUserId !== null
|
||||
? User::query()->find($this->initiatorUserId)
|
||||
: null;
|
||||
|
||||
$operationRun = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'findings.lifecycle.backfill',
|
||||
identityInputs: [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'trigger' => 'backfill',
|
||||
],
|
||||
context: [
|
||||
'workspace_id' => $this->workspaceId,
|
||||
'initiator_user_id' => $this->initiatorUserId,
|
||||
],
|
||||
initiator: $initiator instanceof User ? $initiator : null,
|
||||
);
|
||||
|
||||
$lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900);
|
||||
|
||||
if (! $lock->get()) {
|
||||
if ($operationRun->status !== OperationRunStatus::Completed->value) {
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Blocked->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'findings.lifecycle.backfill.lock_busy',
|
||||
'message' => 'Another findings lifecycle backfill is already running for this tenant.',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$runbookService->maybeFinalize($operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$total = (int) Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->count();
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Running->value,
|
||||
outcome: OperationRunOutcome::Pending->value,
|
||||
summaryCounts: [
|
||||
'total' => $total,
|
||||
'processed' => 0,
|
||||
'updated' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
],
|
||||
);
|
||||
|
||||
$operationRun->refresh();
|
||||
|
||||
$backfillStartedAt = $operationRun->started_at !== null
|
||||
? CarbonImmutable::instance($operationRun->started_at)
|
||||
: CarbonImmutable::now('UTC');
|
||||
|
||||
Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->orderBy('id')
|
||||
->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRuns, $operationRun, $backfillStartedAt): void {
|
||||
$processed = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
if (! $finding instanceof Finding) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$processed++;
|
||||
|
||||
$originalAttributes = $finding->getAttributes();
|
||||
|
||||
$this->backfillLifecycleFields($finding, $backfillStartedAt);
|
||||
$this->backfillLegacyAcknowledgedStatus($finding);
|
||||
$this->backfillSlaFields($finding, $tenant, $slaPolicy, $backfillStartedAt);
|
||||
$this->backfillDriftRecurrenceKey($finding);
|
||||
|
||||
if ($finding->isDirty()) {
|
||||
$finding->save();
|
||||
$updated++;
|
||||
} else {
|
||||
$finding->setRawAttributes($originalAttributes, sync: true);
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
$operationRuns->incrementSummaryCounts($operationRun, [
|
||||
'processed' => $processed,
|
||||
'updated' => $updated,
|
||||
'skipped' => $skipped,
|
||||
]);
|
||||
});
|
||||
|
||||
$consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt);
|
||||
|
||||
if ($consolidatedDuplicates > 0) {
|
||||
$operationRuns->incrementSummaryCounts($operationRun, [
|
||||
'updated' => $consolidatedDuplicates,
|
||||
]);
|
||||
}
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
);
|
||||
|
||||
$runbookService->maybeFinalize($operationRun);
|
||||
} catch (Throwable $e) {
|
||||
$message = RunFailureSanitizer::sanitizeMessage($e->getMessage());
|
||||
$reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage());
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [[
|
||||
'code' => 'findings.lifecycle.backfill.failed',
|
||||
'reason_code' => $reasonCode,
|
||||
'message' => $message !== '' ? $message : 'Findings lifecycle backfill failed.',
|
||||
]],
|
||||
);
|
||||
|
||||
$runbookService->maybeFinalize($operationRun);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillLifecycleFields(Finding $finding, CarbonImmutable $backfillStartedAt): void
|
||||
{
|
||||
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at) : $backfillStartedAt;
|
||||
|
||||
if ($finding->first_seen_at === null) {
|
||||
$finding->first_seen_at = $createdAt;
|
||||
}
|
||||
|
||||
if ($finding->last_seen_at === null) {
|
||||
$finding->last_seen_at = $createdAt;
|
||||
}
|
||||
|
||||
if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) {
|
||||
$lastSeen = CarbonImmutable::instance($finding->last_seen_at);
|
||||
$firstSeen = CarbonImmutable::instance($finding->first_seen_at);
|
||||
|
||||
if ($lastSeen->lessThan($firstSeen)) {
|
||||
$finding->last_seen_at = $firstSeen;
|
||||
}
|
||||
}
|
||||
|
||||
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
||||
|
||||
if ($timesSeen < 1) {
|
||||
$finding->times_seen = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillLegacyAcknowledgedStatus(Finding $finding): void
|
||||
{
|
||||
if ($finding->status !== Finding::STATUS_ACKNOWLEDGED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$finding->status = Finding::STATUS_TRIAGED;
|
||||
|
||||
if ($finding->triaged_at === null) {
|
||||
if ($finding->acknowledged_at !== null) {
|
||||
$finding->triaged_at = CarbonImmutable::instance($finding->acknowledged_at);
|
||||
} elseif ($finding->created_at !== null) {
|
||||
$finding->triaged_at = CarbonImmutable::instance($finding->created_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillSlaFields(
|
||||
Finding $finding,
|
||||
Tenant $tenant,
|
||||
FindingSlaPolicy $slaPolicy,
|
||||
CarbonImmutable $backfillStartedAt,
|
||||
): void {
|
||||
if (! Finding::isOpenStatus((string) $finding->status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($finding->sla_days === null) {
|
||||
$finding->sla_days = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant);
|
||||
}
|
||||
|
||||
if ($finding->due_at === null) {
|
||||
$finding->due_at = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $backfillStartedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillDriftRecurrenceKey(Finding $finding): void
|
||||
{
|
||||
if ($finding->finding_type !== Finding::FINDING_TYPE_DRIFT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($finding->recurrence_key !== null && trim((string) $finding->recurrence_key) !== '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenantId = (int) ($finding->tenant_id ?? 0);
|
||||
$scopeKey = (string) ($finding->scope_key ?? '');
|
||||
$subjectType = (string) ($finding->subject_type ?? '');
|
||||
$subjectExternalId = (string) ($finding->subject_external_id ?? '');
|
||||
|
||||
if ($tenantId <= 0 || $scopeKey === '' || $subjectType === '' || $subjectExternalId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||
$kind = Arr::get($evidence, 'summary.kind');
|
||||
$changeType = Arr::get($evidence, 'change_type');
|
||||
|
||||
$kind = is_string($kind) ? $kind : '';
|
||||
$changeType = is_string($changeType) ? $changeType : '';
|
||||
|
||||
if ($kind === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$dimension = $this->recurrenceDimension($kind, $changeType);
|
||||
|
||||
$finding->recurrence_key = hash('sha256', sprintf(
|
||||
'drift:%d:%s:%s:%s:%s',
|
||||
$tenantId,
|
||||
$scopeKey,
|
||||
$subjectType,
|
||||
$subjectExternalId,
|
||||
$dimension,
|
||||
));
|
||||
}
|
||||
|
||||
private function recurrenceDimension(string $kind, string $changeType): string
|
||||
{
|
||||
$kind = strtolower(trim($kind));
|
||||
$changeType = strtolower(trim($changeType));
|
||||
|
||||
return match ($kind) {
|
||||
'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType !== '' ? $changeType : 'modified'),
|
||||
default => $kind,
|
||||
};
|
||||
}
|
||||
|
||||
private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $backfillStartedAt): int
|
||||
{
|
||||
$duplicateKeys = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->whereNotNull('recurrence_key')
|
||||
->select(['recurrence_key'])
|
||||
->groupBy('recurrence_key')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->pluck('recurrence_key')
|
||||
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->values();
|
||||
|
||||
if ($duplicateKeys->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$consolidated = 0;
|
||||
|
||||
foreach ($duplicateKeys as $recurrenceKey) {
|
||||
if (! is_string($recurrenceKey) || $recurrenceKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('recurrence_key', $recurrenceKey)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$canonical = $this->chooseCanonicalDriftFinding($findings, $recurrenceKey);
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
if (! $finding instanceof Finding) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($canonical instanceof Finding && (int) $finding->getKey() === (int) $canonical->getKey()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'closed_at' => $backfillStartedAt,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
'closed_by_user_id' => null,
|
||||
'recurrence_key' => null,
|
||||
])->save();
|
||||
|
||||
$consolidated++;
|
||||
}
|
||||
}
|
||||
|
||||
return $consolidated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Finding> $findings
|
||||
*/
|
||||
private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding
|
||||
{
|
||||
if ($findings->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status));
|
||||
|
||||
$candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings;
|
||||
|
||||
$alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey);
|
||||
|
||||
if ($alreadyCanonical instanceof Finding) {
|
||||
return $alreadyCanonical;
|
||||
}
|
||||
|
||||
/** @var Finding $sorted */
|
||||
$sorted = $candidates
|
||||
->sortByDesc(function (Finding $finding): array {
|
||||
$lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0;
|
||||
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0;
|
||||
|
||||
return [
|
||||
max($lastSeen, $createdAt),
|
||||
(int) $finding->getKey(),
|
||||
];
|
||||
})
|
||||
->first();
|
||||
|
||||
return $sorted;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,378 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
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 Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Throwable;
|
||||
|
||||
class BackfillFindingLifecycleTenantIntoWorkspaceRunJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $operationRunId,
|
||||
public readonly int $workspaceId,
|
||||
public readonly int $tenantId,
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
OperationRunService $operationRunService,
|
||||
FindingSlaPolicy $slaPolicy,
|
||||
FindingsLifecycleBackfillRunbookService $runbookService,
|
||||
): void {
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $tenant->workspace_id !== $this->workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$run = OperationRun::query()->find($this->operationRunId);
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $run->workspace_id !== $this->workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($run->tenant_id !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($run->status === 'queued') {
|
||||
$operationRunService->updateRun($run, status: 'running');
|
||||
}
|
||||
|
||||
$lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900);
|
||||
|
||||
if (! $lock->get()) {
|
||||
$operationRunService->appendFailures($run, [
|
||||
[
|
||||
'code' => 'findings.lifecycle.backfill.lock_busy',
|
||||
'message' => sprintf('Tenant %d is already running a findings lifecycle backfill.', $this->tenantId),
|
||||
],
|
||||
]);
|
||||
|
||||
$operationRunService->incrementSummaryCounts($run, [
|
||||
'failed' => 1,
|
||||
'processed' => 1,
|
||||
]);
|
||||
|
||||
$operationRunService->maybeCompleteBulkRun($run);
|
||||
$runbookService->maybeFinalize($run);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$backfillStartedAt = $run->started_at !== null
|
||||
? CarbonImmutable::instance($run->started_at)
|
||||
: CarbonImmutable::now('UTC');
|
||||
|
||||
Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->orderBy('id')
|
||||
->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRunService, $run, $backfillStartedAt): void {
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
if (! $finding instanceof Finding) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$originalAttributes = $finding->getAttributes();
|
||||
|
||||
$this->backfillLifecycleFields($finding, $backfillStartedAt);
|
||||
$this->backfillLegacyAcknowledgedStatus($finding);
|
||||
$this->backfillSlaFields($finding, $tenant, $slaPolicy, $backfillStartedAt);
|
||||
$this->backfillDriftRecurrenceKey($finding);
|
||||
|
||||
if ($finding->isDirty()) {
|
||||
$finding->save();
|
||||
$updated++;
|
||||
} else {
|
||||
$finding->setRawAttributes($originalAttributes, sync: true);
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($updated > 0 || $skipped > 0) {
|
||||
$operationRunService->incrementSummaryCounts($run, [
|
||||
'updated' => $updated,
|
||||
'skipped' => $skipped,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
$consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt);
|
||||
|
||||
if ($consolidatedDuplicates > 0) {
|
||||
$operationRunService->incrementSummaryCounts($run, [
|
||||
'updated' => $consolidatedDuplicates,
|
||||
]);
|
||||
}
|
||||
|
||||
$operationRunService->incrementSummaryCounts($run, [
|
||||
'processed' => 1,
|
||||
]);
|
||||
|
||||
$operationRunService->maybeCompleteBulkRun($run);
|
||||
$runbookService->maybeFinalize($run);
|
||||
} catch (Throwable $e) {
|
||||
$message = RunFailureSanitizer::sanitizeMessage($e->getMessage());
|
||||
$reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage());
|
||||
|
||||
$operationRunService->appendFailures($run, [[
|
||||
'code' => 'findings.lifecycle.backfill.failed',
|
||||
'reason_code' => $reasonCode,
|
||||
'message' => $message !== '' ? $message : sprintf('Tenant %d findings lifecycle backfill failed.', $this->tenantId),
|
||||
]]);
|
||||
|
||||
$operationRunService->incrementSummaryCounts($run, [
|
||||
'failed' => 1,
|
||||
'processed' => 1,
|
||||
]);
|
||||
|
||||
$operationRunService->maybeCompleteBulkRun($run);
|
||||
$runbookService->maybeFinalize($run);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillLifecycleFields(Finding $finding, CarbonImmutable $backfillStartedAt): void
|
||||
{
|
||||
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at) : $backfillStartedAt;
|
||||
|
||||
if ($finding->first_seen_at === null) {
|
||||
$finding->first_seen_at = $createdAt;
|
||||
}
|
||||
|
||||
if ($finding->last_seen_at === null) {
|
||||
$finding->last_seen_at = $createdAt;
|
||||
}
|
||||
|
||||
if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) {
|
||||
$lastSeen = CarbonImmutable::instance($finding->last_seen_at);
|
||||
$firstSeen = CarbonImmutable::instance($finding->first_seen_at);
|
||||
|
||||
if ($lastSeen->lessThan($firstSeen)) {
|
||||
$finding->last_seen_at = $firstSeen;
|
||||
}
|
||||
}
|
||||
|
||||
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
||||
|
||||
if ($timesSeen < 1) {
|
||||
$finding->times_seen = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillLegacyAcknowledgedStatus(Finding $finding): void
|
||||
{
|
||||
if ($finding->status !== Finding::STATUS_ACKNOWLEDGED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$finding->status = Finding::STATUS_TRIAGED;
|
||||
|
||||
if ($finding->triaged_at === null) {
|
||||
if ($finding->acknowledged_at !== null) {
|
||||
$finding->triaged_at = CarbonImmutable::instance($finding->acknowledged_at);
|
||||
} elseif ($finding->created_at !== null) {
|
||||
$finding->triaged_at = CarbonImmutable::instance($finding->created_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillSlaFields(
|
||||
Finding $finding,
|
||||
Tenant $tenant,
|
||||
FindingSlaPolicy $slaPolicy,
|
||||
CarbonImmutable $backfillStartedAt,
|
||||
): void {
|
||||
if (! Finding::isOpenStatus((string) $finding->status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($finding->sla_days === null) {
|
||||
$finding->sla_days = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant);
|
||||
}
|
||||
|
||||
if ($finding->due_at === null) {
|
||||
$finding->due_at = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $backfillStartedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillDriftRecurrenceKey(Finding $finding): void
|
||||
{
|
||||
if ($finding->finding_type !== Finding::FINDING_TYPE_DRIFT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($finding->recurrence_key !== null && trim((string) $finding->recurrence_key) !== '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenantId = (int) ($finding->tenant_id ?? 0);
|
||||
$scopeKey = (string) ($finding->scope_key ?? '');
|
||||
$subjectType = (string) ($finding->subject_type ?? '');
|
||||
$subjectExternalId = (string) ($finding->subject_external_id ?? '');
|
||||
|
||||
if ($tenantId <= 0 || $scopeKey === '' || $subjectType === '' || $subjectExternalId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||
$kind = Arr::get($evidence, 'summary.kind');
|
||||
$changeType = Arr::get($evidence, 'change_type');
|
||||
|
||||
$kind = is_string($kind) ? $kind : '';
|
||||
$changeType = is_string($changeType) ? $changeType : '';
|
||||
|
||||
if ($kind === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$dimension = $this->recurrenceDimension($kind, $changeType);
|
||||
|
||||
$finding->recurrence_key = hash('sha256', sprintf(
|
||||
'drift:%d:%s:%s:%s:%s',
|
||||
$tenantId,
|
||||
$scopeKey,
|
||||
$subjectType,
|
||||
$subjectExternalId,
|
||||
$dimension,
|
||||
));
|
||||
}
|
||||
|
||||
private function recurrenceDimension(string $kind, string $changeType): string
|
||||
{
|
||||
$kind = strtolower(trim($kind));
|
||||
$changeType = strtolower(trim($changeType));
|
||||
|
||||
return match ($kind) {
|
||||
'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType !== '' ? $changeType : 'modified'),
|
||||
default => $kind,
|
||||
};
|
||||
}
|
||||
|
||||
private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $backfillStartedAt): int
|
||||
{
|
||||
$duplicateKeys = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->whereNotNull('recurrence_key')
|
||||
->select(['recurrence_key'])
|
||||
->groupBy('recurrence_key')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->pluck('recurrence_key')
|
||||
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->values();
|
||||
|
||||
if ($duplicateKeys->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$consolidated = 0;
|
||||
|
||||
foreach ($duplicateKeys as $recurrenceKey) {
|
||||
if (! is_string($recurrenceKey) || $recurrenceKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('recurrence_key', $recurrenceKey)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$canonical = $this->chooseCanonicalDriftFinding($findings, $recurrenceKey);
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
if (! $finding instanceof Finding) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($canonical instanceof Finding && (int) $finding->getKey() === (int) $canonical->getKey()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'closed_at' => $backfillStartedAt,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
'closed_by_user_id' => null,
|
||||
'recurrence_key' => null,
|
||||
])->save();
|
||||
|
||||
$consolidated++;
|
||||
}
|
||||
}
|
||||
|
||||
return $consolidated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Finding> $findings
|
||||
*/
|
||||
private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding
|
||||
{
|
||||
if ($findings->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status));
|
||||
|
||||
$candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings;
|
||||
|
||||
$alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey);
|
||||
|
||||
if ($alreadyCanonical instanceof Finding) {
|
||||
return $alreadyCanonical;
|
||||
}
|
||||
|
||||
/** @var Finding $sorted */
|
||||
$sorted = $candidates
|
||||
->sortByDesc(function (Finding $finding): array {
|
||||
$lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0;
|
||||
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0;
|
||||
|
||||
return [
|
||||
max($lastSeen, $createdAt),
|
||||
(int) $finding->getKey(),
|
||||
];
|
||||
})
|
||||
->first();
|
||||
|
||||
return $sorted;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\System\AllowedTenantUniverse;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BackfillFindingLifecycleWorkspaceJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $operationRunId,
|
||||
public readonly int $workspaceId,
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
OperationRunService $operationRunService,
|
||||
AllowedTenantUniverse $allowedTenantUniverse,
|
||||
FindingsLifecycleBackfillRunbookService $runbookService,
|
||||
): void {
|
||||
$run = OperationRun::query()->find($this->operationRunId);
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $run->workspace_id !== $this->workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($run->tenant_id !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenantIds = $allowedTenantUniverse
|
||||
->query()
|
||||
->where('workspace_id', $this->workspaceId)
|
||||
->orderBy('id')
|
||||
->pluck('id')
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
$tenantCount = count($tenantIds);
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$run,
|
||||
status: OperationRunStatus::Running->value,
|
||||
outcome: OperationRunOutcome::Pending->value,
|
||||
summaryCounts: [
|
||||
'tenants' => $tenantCount,
|
||||
'total' => $tenantCount,
|
||||
'processed' => 0,
|
||||
'updated' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
],
|
||||
);
|
||||
|
||||
if ($tenantCount === 0) {
|
||||
$operationRunService->updateRun(
|
||||
$run,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
);
|
||||
|
||||
$runbookService->maybeFinalize($run);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
if ($tenantId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
BackfillFindingLifecycleTenantIntoWorkspaceRunJob::dispatch(
|
||||
operationRunId: (int) $run->getKey(),
|
||||
workspaceId: $this->workspaceId,
|
||||
tenantId: $tenantId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1871,11 +1871,8 @@ private function upsertFindings(
|
||||
} else {
|
||||
$this->observeFinding(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
observedAt: $observedAt,
|
||||
currentOperationRunId: (int) $this->operationRun->getKey(),
|
||||
severity: (string) $driftItem['severity'],
|
||||
slaPolicy: $slaPolicy,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1950,21 +1947,12 @@ private function upsertFindings(
|
||||
];
|
||||
}
|
||||
|
||||
private function observeFinding(
|
||||
Finding $finding,
|
||||
Tenant $tenant,
|
||||
CarbonImmutable $observedAt,
|
||||
int $currentOperationRunId,
|
||||
string $severity,
|
||||
FindingSlaPolicy $slaPolicy,
|
||||
): void
|
||||
private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void
|
||||
{
|
||||
if ($finding->first_seen_at === null) {
|
||||
$finding->first_seen_at = $observedAt;
|
||||
}
|
||||
|
||||
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
|
||||
|
||||
if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) {
|
||||
$finding->last_seen_at = $observedAt;
|
||||
}
|
||||
@ -1976,14 +1964,6 @@ private function observeFinding(
|
||||
} elseif ($timesSeen < 1) {
|
||||
$finding->times_seen = 1;
|
||||
}
|
||||
|
||||
if ($finding->sla_days === null) {
|
||||
$finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant);
|
||||
}
|
||||
|
||||
if ($finding->due_at === null) {
|
||||
$finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -452,11 +452,6 @@ private function logVerificationResult(
|
||||
'verification_status' => $connection->verification_status?->value ?? $connection->verification_status,
|
||||
'credential_source' => $identity->credentialSource,
|
||||
'effective_client_id' => $identity->effectiveClientId,
|
||||
'target_scope' => $identity->targetScope?->toArray(),
|
||||
'provider_identity_context' => array_map(
|
||||
static fn ($detail): array => $detail->toArray(),
|
||||
$identity->contextualIdentityDetails,
|
||||
),
|
||||
'reason_code' => $reasonCode,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'previous_consent_status' => $previousConsentStatus,
|
||||
|
||||
@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -36,11 +34,11 @@ public function tenant(): BelongsTo
|
||||
public function operationRuns(): HasMany
|
||||
{
|
||||
return $this->hasMany(OperationRun::class, 'tenant_id', 'tenant_id')
|
||||
->whereIn('type', array_values(array_unique(array_merge(
|
||||
OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value),
|
||||
OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleRetention->value),
|
||||
OperationCatalog::rawValuesForCanonical(OperationRunType::BackupSchedulePurge->value),
|
||||
))))
|
||||
->whereIn('type', [
|
||||
'backup_schedule_run',
|
||||
'backup_schedule_retention',
|
||||
'backup_schedule_purge',
|
||||
])
|
||||
->where('context->backup_schedule_id', (int) $this->getKey());
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,6 +33,8 @@ class Finding extends Model
|
||||
|
||||
public const string STATUS_NEW = 'new';
|
||||
|
||||
public const string STATUS_ACKNOWLEDGED = 'acknowledged';
|
||||
|
||||
public const string STATUS_TRIAGED = 'triaged';
|
||||
|
||||
public const string STATUS_IN_PROGRESS = 'in_progress';
|
||||
@ -167,7 +169,10 @@ public static function terminalStatuses(): array
|
||||
*/
|
||||
public static function openStatusesForQuery(): array
|
||||
{
|
||||
return self::openStatuses();
|
||||
return [
|
||||
...self::openStatuses(),
|
||||
self::STATUS_ACKNOWLEDGED,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -290,6 +295,10 @@ public static function isReopenReason(?string $reason): bool
|
||||
|
||||
public static function canonicalizeStatus(?string $status): ?string
|
||||
{
|
||||
if ($status === self::STATUS_ACKNOWLEDGED) {
|
||||
return self::STATUS_TRIAGED;
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
@ -315,6 +324,23 @@ public function isRiskAccepted(): bool
|
||||
return (string) $this->status === self::STATUS_RISK_ACCEPTED;
|
||||
}
|
||||
|
||||
public function acknowledge(User $user): self
|
||||
{
|
||||
if ($this->status === self::STATUS_ACKNOWLEDGED) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->forceFill([
|
||||
'status' => self::STATUS_ACKNOWLEDGED,
|
||||
'acknowledged_at' => now(),
|
||||
'acknowledged_by_user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->save();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function resolve(string $reason): self
|
||||
{
|
||||
$this->forceFill([
|
||||
|
||||
@ -98,7 +98,7 @@ public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|i
|
||||
: (int) $profile;
|
||||
|
||||
return $query
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCompare->value))
|
||||
->where('type', OperationRunType::BaselineCompare->value)
|
||||
->where('context->baseline_profile_id', $profileId);
|
||||
}
|
||||
|
||||
@ -112,7 +112,7 @@ public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $poli
|
||||
foreach ($policy->coveredTypeNames() as $type) {
|
||||
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
||||
$typeQuery
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical($type))
|
||||
->where('type', $type)
|
||||
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
||||
$stateQuery
|
||||
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
||||
@ -152,18 +152,12 @@ public function scopeHealthyActive(Builder $query, ?OperationLifecyclePolicy $po
|
||||
return $query
|
||||
->active()
|
||||
->where(function (Builder $query) use ($coveredTypes, $policy): void {
|
||||
$coveredRawTypes = collect($coveredTypes)
|
||||
->flatMap(static fn (string $type): array => OperationCatalog::rawValuesForCanonical($type))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$query->whereNotIn('type', $coveredRawTypes);
|
||||
$query->whereNotIn('type', $coveredTypes);
|
||||
|
||||
foreach ($coveredTypes as $type) {
|
||||
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
||||
$typeQuery
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical($type))
|
||||
->where('type', $type)
|
||||
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
||||
$stateQuery
|
||||
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
||||
@ -349,7 +343,7 @@ public static function latestCompletedCoverageBearingInventorySyncForTenant(int
|
||||
|
||||
return static::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value))
|
||||
->where('type', 'inventory_sync')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereNotNull('completed_at')
|
||||
->latest('completed_at')
|
||||
@ -484,11 +478,11 @@ public function baselineGapEnvelope(): array
|
||||
{
|
||||
$context = is_array($this->context) ? $this->context : [];
|
||||
|
||||
return match ($this->canonicalOperationType()) {
|
||||
'baseline.compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
|
||||
return match ((string) $this->type) {
|
||||
'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
|
||||
? data_get($context, 'baseline_compare.evidence_gaps')
|
||||
: [],
|
||||
'baseline.capture' => is_array(data_get($context, 'baseline_capture.gaps'))
|
||||
'baseline_capture' => is_array(data_get($context, 'baseline_capture.gaps'))
|
||||
? data_get($context, 'baseline_capture.gaps')
|
||||
: [],
|
||||
default => [],
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\OperationalControlActivationFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class OperationalControlActivation extends Model
|
||||
{
|
||||
/** @use HasFactory<OperationalControlActivationFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function newFactory(): OperationalControlActivationFactory
|
||||
{
|
||||
return OperationalControlActivationFactory::new();
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PlatformUser::class, 'created_by_platform_user_id');
|
||||
}
|
||||
|
||||
public function updatedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PlatformUser::class, 'updated_by_platform_user_id');
|
||||
}
|
||||
|
||||
public function scopeForControl(Builder $query, string $controlKey): Builder
|
||||
{
|
||||
return $query->where('control_key', trim($controlKey));
|
||||
}
|
||||
|
||||
public function scopeForGlobalScope(Builder $query): Builder
|
||||
{
|
||||
return $query->where('scope_type', 'global');
|
||||
}
|
||||
|
||||
public function scopeForWorkspaceScope(Builder $query, int|Workspace $workspace): Builder
|
||||
{
|
||||
$workspaceId = $workspace instanceof Workspace
|
||||
? (int) $workspace->getKey()
|
||||
: (int) $workspace;
|
||||
|
||||
return $query
|
||||
->where('scope_type', 'workspace')
|
||||
->where('workspace_id', $workspaceId);
|
||||
}
|
||||
|
||||
public function scopeNotExpired(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query): void {
|
||||
$query
|
||||
->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProductUsageEvent extends Model
|
||||
{
|
||||
use DerivesWorkspaceIdFromTenant;
|
||||
|
||||
/** @use HasFactory<\Database\Factories\ProductUsageEventFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'metadata' => 'array',
|
||||
'occurred_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class)->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@ -1,182 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SupportRequest extends Model
|
||||
{
|
||||
use DerivesWorkspaceIdFromTenant;
|
||||
|
||||
/** @use HasFactory<\Database\Factories\SupportRequestFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const string PRIMARY_CONTEXT_TENANT = 'tenant';
|
||||
|
||||
public const string PRIMARY_CONTEXT_OPERATION_RUN = 'operation_run';
|
||||
|
||||
public const string ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED = 'diagnostic_snapshot_attached';
|
||||
|
||||
public const string ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY = 'canonical_context_only';
|
||||
|
||||
public const string SEVERITY_LOW = 'low';
|
||||
|
||||
public const string SEVERITY_NORMAL = 'normal';
|
||||
|
||||
public const string SEVERITY_HIGH = 'high';
|
||||
|
||||
public const string SEVERITY_BLOCKING = 'blocking';
|
||||
|
||||
public const string EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY = 'internal_only';
|
||||
|
||||
public const string EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET = 'create_external_ticket';
|
||||
|
||||
public const string EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET = 'link_existing_ticket';
|
||||
|
||||
public const string HANDOFF_OUTCOME_INTERNAL_ONLY = 'internal_only';
|
||||
|
||||
public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED = 'external_ticket_created';
|
||||
|
||||
public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED = 'external_ticket_linked';
|
||||
|
||||
public const string HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED = 'external_handoff_failed';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'context_envelope' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function severityOptions(): array
|
||||
{
|
||||
return [
|
||||
self::SEVERITY_LOW => 'Low',
|
||||
self::SEVERITY_NORMAL => 'Normal',
|
||||
self::SEVERITY_HIGH => 'High',
|
||||
self::SEVERITY_BLOCKING => 'Blocking',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function severityValues(): array
|
||||
{
|
||||
return array_keys(self::severityOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function externalHandoffModeOptions(): array
|
||||
{
|
||||
return [
|
||||
self::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => 'TenantPilot only',
|
||||
self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => 'Create external ticket',
|
||||
self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => 'Link existing ticket',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function externalHandoffModeValues(): array
|
||||
{
|
||||
return array_keys(self::externalHandoffModeOptions());
|
||||
}
|
||||
|
||||
public function hasExternalTicket(): bool
|
||||
{
|
||||
return is_string($this->external_ticket_reference) && trim($this->external_ticket_reference) !== '';
|
||||
}
|
||||
|
||||
public function hasExternalHandoffFailure(): bool
|
||||
{
|
||||
return is_string($this->external_handoff_failure_summary) && trim($this->external_handoff_failure_summary) !== '';
|
||||
}
|
||||
|
||||
public function externalHandoffOutcome(): string
|
||||
{
|
||||
if ($this->hasExternalHandoffFailure()) {
|
||||
return self::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED;
|
||||
}
|
||||
|
||||
if (! $this->hasExternalTicket()) {
|
||||
return self::HANDOFF_OUTCOME_INTERNAL_ONLY;
|
||||
}
|
||||
|
||||
return match ($this->external_handoff_mode) {
|
||||
self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED,
|
||||
self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED,
|
||||
default => self::HANDOFF_OUTCOME_INTERNAL_ONLY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function primaryContextTypes(): array
|
||||
{
|
||||
return [
|
||||
self::PRIMARY_CONTEXT_TENANT,
|
||||
self::PRIMARY_CONTEXT_OPERATION_RUN,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function attachmentModes(): array
|
||||
{
|
||||
return [
|
||||
self::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
|
||||
self::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<OperationRun, $this>
|
||||
*/
|
||||
public function operationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OperationRun::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function initiator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
||||
}
|
||||
}
|
||||
@ -39,7 +39,6 @@ class User extends Authenticatable implements FilamentUser, HasDefaultTenant, Ha
|
||||
'password',
|
||||
'entra_tenant_id',
|
||||
'entra_object_id',
|
||||
'preferred_locale',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -49,7 +49,10 @@ public function update(User $user, Finding $finding): Response|bool
|
||||
|
||||
public function triage(User $user, Finding $finding): Response|bool
|
||||
{
|
||||
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_TRIAGE);
|
||||
return $this->canMutateWithAnyCapability($user, $finding, [
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
]);
|
||||
}
|
||||
|
||||
public function assign(User $user, Finding $finding): Response|bool
|
||||
|
||||
@ -5,16 +5,13 @@
|
||||
use App\Filament\Pages\Auth\Login;
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Filament\Pages\CrossTenantComparePage;
|
||||
use App\Filament\Pages\Findings\FindingsHygieneReport;
|
||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||
use App\Filament\Pages\InventoryCoverage;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Pages\NoAccess;
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||
use App\Filament\Pages\TenantRequiredPermissions;
|
||||
use App\Filament\Pages\WorkspaceOverview;
|
||||
@ -80,16 +77,16 @@ public function panel(Panel $panel): Panel
|
||||
])
|
||||
->navigationItems([
|
||||
WorkspaceOverview::navigationItem(),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.integrations'))
|
||||
NavigationItem::make('Integrations')
|
||||
->url(fn (): string => route('filament.admin.resources.provider-connections.index'))
|
||||
->icon('heroicon-o-link')
|
||||
->group(fn (): string => __('localization.navigation.settings'))
|
||||
->group('Settings')
|
||||
->sort(15)
|
||||
->visible(fn (): bool => ProviderConnectionResource::canViewAny()),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.settings'))
|
||||
NavigationItem::make('Settings')
|
||||
->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin'))
|
||||
->icon('heroicon-o-cog-6-tooth')
|
||||
->group(fn (): string => __('localization.navigation.settings'))
|
||||
->group('Settings')
|
||||
->sort(20)
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
@ -116,12 +113,12 @@ public function panel(Panel $panel): Panel
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW);
|
||||
}),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.manage_workspaces'))
|
||||
NavigationItem::make('Manage workspaces')
|
||||
->url(function (): string {
|
||||
return route('filament.admin.resources.workspaces.index');
|
||||
})
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->group(fn (): string => __('localization.navigation.settings'))
|
||||
->group('Settings')
|
||||
->sort(10)
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
@ -137,15 +134,15 @@ public function panel(Panel $panel): Panel
|
||||
->whereIn('role', $roles)
|
||||
->exists();
|
||||
}),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.operations'))
|
||||
NavigationItem::make('Operations')
|
||||
->url(fn (): string => route('admin.operations.index'))
|
||||
->icon('heroicon-o-queue-list')
|
||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
||||
->group('Monitoring')
|
||||
->sort(10),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.audit_log'))
|
||||
NavigationItem::make('Audit Log')
|
||||
->url(fn (): string => route('admin.monitoring.audit-log'))
|
||||
->icon('heroicon-o-clipboard-document-list')
|
||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
||||
->group('Monitoring')
|
||||
->sort(30),
|
||||
])
|
||||
->renderHook(
|
||||
@ -182,13 +179,10 @@ public function panel(Panel $panel): Panel
|
||||
InventoryCoverage::class,
|
||||
TenantRequiredPermissions::class,
|
||||
WorkspaceSettings::class,
|
||||
CrossTenantComparePage::class,
|
||||
GovernanceInbox::class,
|
||||
FindingsHygieneReport::class,
|
||||
FindingsIntakeQueue::class,
|
||||
MyFindingsInbox::class,
|
||||
FindingExceptionsQueue::class,
|
||||
CustomerReviewWorkspace::class,
|
||||
ReviewRegister::class,
|
||||
])
|
||||
->widgets([
|
||||
@ -212,7 +206,6 @@ public function panel(Panel $panel): Panel
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->middleware(['apply-resolved-locale:admin'], isPersistent: true)
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
|
||||
@ -42,14 +42,6 @@ public function panel(Panel $panel): Panel
|
||||
PanelsRenderHook::BODY_START,
|
||||
fn () => view('filament.system.components.break-glass-banner')->render(),
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::TOPBAR_START,
|
||||
fn () => view('filament.partials.locale-switcher', [
|
||||
'plane' => 'system',
|
||||
'showPreference' => false,
|
||||
'embedded' => false,
|
||||
])->render(),
|
||||
)
|
||||
->discoverPages(in: app_path('Filament/System/Pages'), for: 'App\\Filament\\System\\Pages')
|
||||
->pages([
|
||||
Dashboard::class,
|
||||
@ -67,7 +59,6 @@ public function panel(Panel $panel): Panel
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->middleware(['apply-resolved-locale:system'], isPersistent: true)
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
|
||||
@ -50,20 +50,20 @@ public function panel(Panel $panel): Panel
|
||||
'primary' => Color::Indigo,
|
||||
])
|
||||
->navigationItems([
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.operations'))
|
||||
NavigationItem::make(OperationRunLinks::collectionLabel())
|
||||
->url(fn (): string => route('admin.operations.index'))
|
||||
->icon('heroicon-o-queue-list')
|
||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
||||
->group('Monitoring')
|
||||
->sort(10),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.alerts'))
|
||||
NavigationItem::make('Alerts')
|
||||
->url(fn (): string => url('/admin/alerts'))
|
||||
->icon('heroicon-o-bell-alert')
|
||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
||||
->group('Monitoring')
|
||||
->sort(20),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.audit_log'))
|
||||
NavigationItem::make('Audit Log')
|
||||
->url(fn (): string => route('admin.monitoring.audit-log'))
|
||||
->icon('heroicon-o-clipboard-document-list')
|
||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
||||
->group('Monitoring')
|
||||
->sort(30),
|
||||
])
|
||||
->renderHook(
|
||||
@ -111,7 +111,6 @@ public function panel(Panel $panel): Panel
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->middleware(['apply-resolved-locale:tenant'], isPersistent: true)
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
|
||||
@ -4,9 +4,6 @@
|
||||
|
||||
namespace App\Services\Audit;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\SupportRequest;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
@ -15,7 +12,6 @@
|
||||
use App\Support\Audit\AuditActorType;
|
||||
use App\Support\Audit\AuditTargetSnapshot;
|
||||
use Carbon\CarbonImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class WorkspaceAuditLogger
|
||||
{
|
||||
@ -27,7 +23,7 @@ public function log(
|
||||
Workspace $workspace,
|
||||
string|AuditActionId $action,
|
||||
array $context = [],
|
||||
User|PlatformUser|null $actor = null,
|
||||
?User $actor = null,
|
||||
string $status = 'success',
|
||||
?string $resourceType = null,
|
||||
?string $resourceId = null,
|
||||
@ -40,16 +36,14 @@ public function log(
|
||||
?int $operationRunId = null,
|
||||
?Tenant $tenant = null,
|
||||
): \App\Models\AuditLog {
|
||||
$resolvedActor = match (true) {
|
||||
$actor instanceof User => AuditActorSnapshot::human($actor),
|
||||
$actor instanceof PlatformUser => AuditActorSnapshot::platform($actor),
|
||||
default => AuditActorSnapshot::fromLegacy(
|
||||
$resolvedActor = $actor instanceof User
|
||||
? AuditActorSnapshot::human($actor)
|
||||
: AuditActorSnapshot::fromLegacy(
|
||||
type: $actorType ?? AuditActorType::infer($action instanceof AuditActionId ? $action->value : $action, $actorId, $actorEmail, $actorName, $context),
|
||||
id: $actorId,
|
||||
email: $actorEmail,
|
||||
label: $actorName,
|
||||
),
|
||||
};
|
||||
);
|
||||
|
||||
return $this->auditRecorder->record(
|
||||
action: $action,
|
||||
@ -76,7 +70,7 @@ public function logTenantLifecycleAction(
|
||||
Tenant $tenant,
|
||||
string|AuditActionId $action,
|
||||
array $context = [],
|
||||
User|PlatformUser|null $actor = null,
|
||||
?User $actor = null,
|
||||
string $status = 'success',
|
||||
?string $summary = null,
|
||||
): \App\Models\AuditLog {
|
||||
@ -93,204 +87,4 @@ public function logTenantLifecycleAction(
|
||||
tenant: $tenant,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $bundle
|
||||
*/
|
||||
public function logSupportDiagnosticsOpened(
|
||||
Tenant $tenant,
|
||||
string $contextType,
|
||||
array $bundle,
|
||||
User|PlatformUser|null $actor = null,
|
||||
?OperationRun $operationRun = null,
|
||||
): \App\Models\AuditLog {
|
||||
$sectionCount = is_array($bundle['sections'] ?? null) ? count($bundle['sections']) : 0;
|
||||
$referenceCount = collect($bundle['sections'] ?? [])
|
||||
->sum(static fn (mixed $section): int => is_array($section) && is_array($section['references'] ?? null)
|
||||
? count($section['references'])
|
||||
: 0);
|
||||
|
||||
return $this->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::SupportDiagnosticsOpened,
|
||||
context: [
|
||||
'context_type' => $contextType,
|
||||
'redaction_mode' => 'default_redacted',
|
||||
'section_count' => $sectionCount,
|
||||
'reference_count' => $referenceCount,
|
||||
'primary_context_id' => $operationRun instanceof OperationRun
|
||||
? (string) $operationRun->getKey()
|
||||
: (string) $tenant->getKey(),
|
||||
],
|
||||
actor: $actor,
|
||||
status: 'success',
|
||||
resourceType: 'support_diagnostic_bundle',
|
||||
resourceId: $operationRun instanceof OperationRun
|
||||
? 'operation_run:'.$operationRun->getKey()
|
||||
: 'tenant:'.$tenant->getKey(),
|
||||
targetLabel: $operationRun instanceof OperationRun
|
||||
? 'Support diagnostics for operation #'.$operationRun->getKey()
|
||||
: 'Support diagnostics for '.$tenant->name,
|
||||
summary: $operationRun instanceof OperationRun
|
||||
? 'Support diagnostics opened for operation #'.$operationRun->getKey()
|
||||
: 'Support diagnostics opened for '.$tenant->name,
|
||||
operationRunId: $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
|
||||
tenant: $tenant,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $preflight
|
||||
*/
|
||||
public function logCrossTenantPromotionPreflightGenerated(
|
||||
Workspace $workspace,
|
||||
Tenant $sourceTenant,
|
||||
Tenant $targetTenant,
|
||||
array $preflight,
|
||||
User|PlatformUser|null $actor = null,
|
||||
): \App\Models\AuditLog {
|
||||
$summary = is_array($preflight['summary'] ?? null) ? $preflight['summary'] : [];
|
||||
|
||||
return $this->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::CrossTenantPromotionPreflightGenerated,
|
||||
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,
|
||||
'ready_count' => (int) ($summary['ready'] ?? 0),
|
||||
'blocked_count' => (int) ($summary['blocked'] ?? 0),
|
||||
'manual_mapping_required_count' => (int) ($summary['manual_mapping_required'] ?? 0),
|
||||
'total_count' => (int) ($summary['total'] ?? 0),
|
||||
'blocked_reason_counts' => is_array($preflight['blockedReasonCounts'] ?? null)
|
||||
? $preflight['blockedReasonCounts']
|
||||
: [],
|
||||
],
|
||||
actor: $actor,
|
||||
status: 'success',
|
||||
resourceType: 'cross_tenant_promotion_preflight',
|
||||
resourceId: sprintf('%s:%s', $sourceTenant->getKey(), $targetTenant->getKey()),
|
||||
targetLabel: $sourceTenant->name.' -> '.$targetTenant->name,
|
||||
summary: 'Cross-tenant promotion preflight generated for '.$sourceTenant->name.' -> '.$targetTenant->name,
|
||||
);
|
||||
}
|
||||
|
||||
public function logSupportRequestCreated(
|
||||
SupportRequest $supportRequest,
|
||||
User|PlatformUser|null $actor = null,
|
||||
): \App\Models\AuditLog {
|
||||
$supportRequest->loadMissing(['tenant.workspace']);
|
||||
|
||||
$tenant = $supportRequest->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new InvalidArgumentException('Support requests must belong to a tenant.');
|
||||
}
|
||||
|
||||
return $this->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::SupportRequestCreated,
|
||||
context: [
|
||||
'internal_reference' => $supportRequest->internal_reference,
|
||||
'primary_context_type' => $supportRequest->primary_context_type,
|
||||
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
|
||||
? (string) $supportRequest->operation_run_id
|
||||
: (string) $tenant->getKey(),
|
||||
'attachment_mode' => $supportRequest->attachment_mode,
|
||||
'redaction_mode' => (string) data_get($supportRequest->context_envelope, 'redaction_mode', 'default_redacted'),
|
||||
],
|
||||
actor: $actor,
|
||||
status: 'success',
|
||||
resourceType: 'support_request',
|
||||
resourceId: (string) $supportRequest->getKey(),
|
||||
targetLabel: $supportRequest->internal_reference,
|
||||
summary: 'Support request created for '.$supportRequest->internal_reference,
|
||||
operationRunId: $supportRequest->operation_run_id !== null ? (int) $supportRequest->operation_run_id : null,
|
||||
tenant: $tenant,
|
||||
);
|
||||
}
|
||||
|
||||
public function logSupportRequestExternalTicketCreated(
|
||||
SupportRequest $supportRequest,
|
||||
User|PlatformUser|null $actor = null,
|
||||
): \App\Models\AuditLog {
|
||||
return $this->logSupportRequestExternalHandoff(
|
||||
supportRequest: $supportRequest,
|
||||
actor: $actor,
|
||||
action: AuditActionId::SupportRequestExternalTicketCreated,
|
||||
status: 'success',
|
||||
summaryPrefix: 'External ticket created for support request ',
|
||||
);
|
||||
}
|
||||
|
||||
public function logSupportRequestExternalTicketLinked(
|
||||
SupportRequest $supportRequest,
|
||||
User|PlatformUser|null $actor = null,
|
||||
): \App\Models\AuditLog {
|
||||
return $this->logSupportRequestExternalHandoff(
|
||||
supportRequest: $supportRequest,
|
||||
actor: $actor,
|
||||
action: AuditActionId::SupportRequestExternalTicketLinked,
|
||||
status: 'success',
|
||||
summaryPrefix: 'External ticket linked for support request ',
|
||||
);
|
||||
}
|
||||
|
||||
public function logSupportRequestExternalHandoffFailed(
|
||||
SupportRequest $supportRequest,
|
||||
User|PlatformUser|null $actor = null,
|
||||
): \App\Models\AuditLog {
|
||||
return $this->logSupportRequestExternalHandoff(
|
||||
supportRequest: $supportRequest,
|
||||
actor: $actor,
|
||||
action: AuditActionId::SupportRequestExternalHandoffFailed,
|
||||
status: 'failed',
|
||||
summaryPrefix: 'External handoff failed for support request ',
|
||||
);
|
||||
}
|
||||
|
||||
private function logSupportRequestExternalHandoff(
|
||||
SupportRequest $supportRequest,
|
||||
User|PlatformUser|null $actor,
|
||||
AuditActionId $action,
|
||||
string $status,
|
||||
string $summaryPrefix,
|
||||
): \App\Models\AuditLog {
|
||||
$supportRequest->loadMissing(['tenant.workspace']);
|
||||
|
||||
$tenant = $supportRequest->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new InvalidArgumentException('Support requests must belong to a tenant.');
|
||||
}
|
||||
|
||||
$metadata = [
|
||||
'internal_reference' => $supportRequest->internal_reference,
|
||||
'primary_context_type' => $supportRequest->primary_context_type,
|
||||
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
|
||||
? (string) $supportRequest->operation_run_id
|
||||
: (string) $tenant->getKey(),
|
||||
'external_handoff_mode' => $supportRequest->external_handoff_mode,
|
||||
'external_ticket_reference' => $supportRequest->external_ticket_reference,
|
||||
];
|
||||
|
||||
if ($supportRequest->external_handoff_failure_summary !== null) {
|
||||
$metadata['external_handoff_failure_summary'] = $supportRequest->external_handoff_failure_summary;
|
||||
}
|
||||
|
||||
return $this->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: $action,
|
||||
context: $metadata,
|
||||
actor: $actor,
|
||||
status: $status,
|
||||
resourceType: 'support_request',
|
||||
resourceId: (string) $supportRequest->getKey(),
|
||||
targetLabel: $supportRequest->internal_reference,
|
||||
summary: $summaryPrefix.$supportRequest->internal_reference,
|
||||
operationRunId: $supportRequest->operation_run_id !== null ? (int) $supportRequest->operation_run_id : null,
|
||||
tenant: $tenant,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,8 +19,6 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_MANAGE,
|
||||
Capabilities::TENANT_DELETE,
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||
Capabilities::SUPPORT_REQUESTS_CREATE,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_VIEW,
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
@ -28,6 +26,7 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_FINDINGS_RESOLVE,
|
||||
Capabilities::TENANT_FINDINGS_CLOSE,
|
||||
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
||||
Capabilities::FINDING_EXCEPTION_MANAGE,
|
||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||
@ -64,8 +63,6 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_VIEW,
|
||||
Capabilities::TENANT_MANAGE,
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||
Capabilities::SUPPORT_REQUESTS_CREATE,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_VIEW,
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
@ -73,6 +70,7 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_FINDINGS_RESOLVE,
|
||||
Capabilities::TENANT_FINDINGS_CLOSE,
|
||||
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
||||
Capabilities::FINDING_EXCEPTION_MANAGE,
|
||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||
@ -105,11 +103,10 @@ class RoleCapabilityMap
|
||||
TenantRole::Operator->value => [
|
||||
Capabilities::TENANT_VIEW,
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||
Capabilities::SUPPORT_REQUESTS_CREATE,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_VIEW,
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
|
||||
@ -14,7 +14,6 @@
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Providers\ProviderOperationStartResult;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class EntraGroupSyncService
|
||||
@ -33,7 +32,7 @@ public function startManualSync(Tenant $tenant, User $user): ProviderOperationSt
|
||||
return $this->providerStarts->start(
|
||||
tenant: $tenant,
|
||||
connection: null,
|
||||
operationType: OperationRunType::DirectoryGroupsSync->value,
|
||||
operationType: 'entra_group_sync',
|
||||
dispatcher: function (OperationRun $run) use ($tenant, $selectionKey): void {
|
||||
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
|
||||
? (int) $run->context['provider_connection_id']
|
||||
|
||||
@ -14,7 +14,6 @@
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Providers\ProviderOperationStartResult;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class RoleDefinitionsSyncService
|
||||
@ -33,7 +32,7 @@ public function startManualSync(Tenant $tenant, User $user): ProviderOperationSt
|
||||
return $this->providerStarts->start(
|
||||
tenant: $tenant,
|
||||
connection: null,
|
||||
operationType: OperationRunType::DirectoryRoleDefinitionsSync->value,
|
||||
operationType: 'directory_role_definitions.sync',
|
||||
dispatcher: function (OperationRun $run) use ($tenant): void {
|
||||
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
|
||||
? (int) $run->context['provider_connection_id']
|
||||
|
||||
@ -1,410 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Entitlements;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Carbon\CarbonInterface;
|
||||
|
||||
final class WorkspaceCommercialLifecycleResolver
|
||||
{
|
||||
public const SETTING_DOMAIN = WorkspaceEntitlementResolver::SETTING_DOMAIN;
|
||||
|
||||
public const SETTING_COMMERCIAL_LIFECYCLE_STATE = 'commercial_lifecycle_state';
|
||||
|
||||
public const SETTING_COMMERCIAL_LIFECYCLE_REASON = 'commercial_lifecycle_reason';
|
||||
|
||||
public const STATE_TRIAL = 'trial';
|
||||
|
||||
public const STATE_GRACE = 'grace';
|
||||
|
||||
public const STATE_ACTIVE_PAID = 'active_paid';
|
||||
|
||||
public const STATE_SUSPENDED_READ_ONLY = 'suspended_read_only';
|
||||
|
||||
public const SOURCE_DEFAULT_ACTIVE_PAID = 'default_active_paid';
|
||||
|
||||
public const SOURCE_WORKSPACE_SETTING = 'workspace_setting';
|
||||
|
||||
public const ACTION_MANAGED_TENANT_ACTIVATION = 'managed_tenant_activation';
|
||||
|
||||
public const ACTION_REVIEW_PACK_START = 'review_pack_start';
|
||||
|
||||
public const ACTION_REVIEW_HISTORY_READ = 'review_history_read';
|
||||
|
||||
public const ACTION_EVIDENCE_READ = 'evidence_read';
|
||||
|
||||
public const ACTION_GENERATED_PACK_READ = 'generated_pack_read';
|
||||
|
||||
public const OUTCOME_ALLOW = 'allow';
|
||||
|
||||
public const OUTCOME_WARN = 'warn';
|
||||
|
||||
public const OUTCOME_BLOCK = 'block';
|
||||
|
||||
public const OUTCOME_ALLOW_READ_ONLY = 'allow_read_only';
|
||||
|
||||
public const REASON_FAMILY_ENTITLEMENT_SUBSTRATE = 'entitlement_substrate';
|
||||
|
||||
public const REASON_FAMILY_COMMERCIAL_LIFECYCLE = 'commercial_lifecycle';
|
||||
|
||||
public function __construct(
|
||||
private readonly SettingsResolver $settingsResolver,
|
||||
private readonly WorkspaceEntitlementResolver $workspaceEntitlementResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function stateIds(): array
|
||||
{
|
||||
return [
|
||||
self::STATE_TRIAL,
|
||||
self::STATE_GRACE,
|
||||
self::STATE_ACTIVE_PAID,
|
||||
self::STATE_SUSPENDED_READ_ONLY,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function stateLabels(): array
|
||||
{
|
||||
return [
|
||||
self::STATE_TRIAL => 'Trial',
|
||||
self::STATE_GRACE => 'Grace',
|
||||
self::STATE_ACTIVE_PAID => 'Active paid',
|
||||
self::STATE_SUSPENDED_READ_ONLY => 'Suspended / read-only',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function stateDescriptions(): array
|
||||
{
|
||||
return [
|
||||
self::STATE_TRIAL => 'Expansion and review-pack starts are available while the workspace is in trial.',
|
||||
self::STATE_GRACE => 'New managed-tenant activation is frozen, but review-pack starts remain available with a warning.',
|
||||
self::STATE_ACTIVE_PAID => 'Expansion and review-pack starts are available for the active paid workspace.',
|
||||
self::STATE_SUSPENDED_READ_ONLY => 'New activation and review-pack starts are blocked while existing review, evidence, and generated-pack access remains read-only.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function summary(Workspace $workspace): array
|
||||
{
|
||||
$lifecycle = $this->resolve($workspace);
|
||||
|
||||
return $lifecycle + [
|
||||
'entitlement_summary' => $this->workspaceEntitlementResolver->summary($workspace),
|
||||
'action_decisions' => [
|
||||
self::ACTION_MANAGED_TENANT_ACTIVATION => $this->actionDecision($workspace, self::ACTION_MANAGED_TENANT_ACTIVATION, $lifecycle),
|
||||
self::ACTION_REVIEW_PACK_START => $this->actionDecision($workspace, self::ACTION_REVIEW_PACK_START, $lifecycle),
|
||||
self::ACTION_REVIEW_HISTORY_READ => $this->actionDecision($workspace, self::ACTION_REVIEW_HISTORY_READ, $lifecycle),
|
||||
self::ACTION_EVIDENCE_READ => $this->actionDecision($workspace, self::ACTION_EVIDENCE_READ, $lifecycle),
|
||||
self::ACTION_GENERATED_PACK_READ => $this->actionDecision($workspace, self::ACTION_GENERATED_PACK_READ, $lifecycle),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* workspace_id: int,
|
||||
* state: string,
|
||||
* state_label: string,
|
||||
* source: string,
|
||||
* source_label: string,
|
||||
* rationale: string|null,
|
||||
* description: string,
|
||||
* last_changed_at: CarbonInterface|null,
|
||||
* last_changed_by: string|null
|
||||
* }
|
||||
*/
|
||||
public function resolve(Workspace $workspace): array
|
||||
{
|
||||
$stateSetting = $this->settingsResolver->resolveDetailed(
|
||||
workspace: $workspace,
|
||||
domain: self::SETTING_DOMAIN,
|
||||
key: self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
||||
);
|
||||
|
||||
$rawState = is_string($stateSetting['value'] ?? null)
|
||||
? strtolower(trim((string) $stateSetting['value']))
|
||||
: 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();
|
||||
$descriptions = self::stateDescriptions();
|
||||
$lastChanged = $this->lastChangedMetadata($workspace);
|
||||
|
||||
return [
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'state' => $state,
|
||||
'state_label' => $labels[$state],
|
||||
'source' => $source,
|
||||
'source_label' => $source === self::SOURCE_WORKSPACE_SETTING
|
||||
? 'workspace setting'
|
||||
: 'default active paid',
|
||||
'rationale' => is_string($rationale) && trim($rationale) !== '' ? trim($rationale) : null,
|
||||
'description' => $descriptions[$state],
|
||||
'last_changed_at' => $lastChanged['last_changed_at'],
|
||||
'last_changed_by' => $lastChanged['last_changed_by'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $lifecycle
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function actionDecision(Workspace $workspace, string $actionKey, ?array $lifecycle = null): array
|
||||
{
|
||||
$lifecycle ??= $this->resolve($workspace);
|
||||
|
||||
return match ($actionKey) {
|
||||
self::ACTION_MANAGED_TENANT_ACTIVATION => $this->managedTenantActivationDecision($workspace, $lifecycle),
|
||||
self::ACTION_REVIEW_PACK_START => $this->reviewPackStartDecision($workspace, $lifecycle),
|
||||
self::ACTION_REVIEW_HISTORY_READ,
|
||||
self::ACTION_EVIDENCE_READ,
|
||||
self::ACTION_GENERATED_PACK_READ => $this->readOnlyDecision($actionKey, $lifecycle),
|
||||
default => throw new \InvalidArgumentException(sprintf('Unknown commercial lifecycle action key: %s', $actionKey)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function reviewPackStartDecisionForTenant(Tenant $tenant): array
|
||||
{
|
||||
$tenant->loadMissing('workspace');
|
||||
|
||||
return $this->actionDecision($tenant->workspace, self::ACTION_REVIEW_PACK_START);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lifecycle
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function managedTenantActivationDecision(Workspace $workspace, array $lifecycle): array
|
||||
{
|
||||
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
|
||||
$workspace,
|
||||
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
||||
);
|
||||
|
||||
if ((bool) ($substrateDecision['is_blocked'] ?? false)) {
|
||||
return $this->decision(
|
||||
lifecycle: $lifecycle,
|
||||
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
||||
outcome: self::OUTCOME_BLOCK,
|
||||
reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
|
||||
message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks managed tenant activation.'),
|
||||
substrateDecision: $substrateDecision,
|
||||
);
|
||||
}
|
||||
|
||||
return match ($lifecycle['state']) {
|
||||
self::STATE_GRACE => $this->decision(
|
||||
lifecycle: $lifecycle,
|
||||
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
||||
outcome: self::OUTCOME_BLOCK,
|
||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
||||
message: 'New managed-tenant activation is frozen while this workspace is in grace.',
|
||||
substrateDecision: $substrateDecision,
|
||||
),
|
||||
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
|
||||
lifecycle: $lifecycle,
|
||||
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
||||
outcome: self::OUTCOME_BLOCK,
|
||||
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.',
|
||||
substrateDecision: $substrateDecision,
|
||||
),
|
||||
default => $this->decision(
|
||||
lifecycle: $lifecycle,
|
||||
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
||||
outcome: self::OUTCOME_ALLOW,
|
||||
reasonFamily: null,
|
||||
message: 'Managed-tenant activation is available for this workspace commercial state.',
|
||||
substrateDecision: $substrateDecision,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lifecycle
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function reviewPackStartDecision(Workspace $workspace, array $lifecycle): array
|
||||
{
|
||||
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
|
||||
$workspace,
|
||||
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
||||
);
|
||||
|
||||
if ((bool) ($substrateDecision['is_blocked'] ?? false)) {
|
||||
return $this->decision(
|
||||
lifecycle: $lifecycle,
|
||||
actionKey: self::ACTION_REVIEW_PACK_START,
|
||||
outcome: self::OUTCOME_BLOCK,
|
||||
reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
|
||||
message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'),
|
||||
substrateDecision: $substrateDecision,
|
||||
);
|
||||
}
|
||||
|
||||
return match ($lifecycle['state']) {
|
||||
self::STATE_GRACE => $this->decision(
|
||||
lifecycle: $lifecycle,
|
||||
actionKey: self::ACTION_REVIEW_PACK_START,
|
||||
outcome: self::OUTCOME_WARN,
|
||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
||||
message: 'Workspace is in grace. Review-pack starts remain available, but managed-tenant expansion is frozen.',
|
||||
substrateDecision: $substrateDecision,
|
||||
),
|
||||
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
|
||||
lifecycle: $lifecycle,
|
||||
actionKey: self::ACTION_REVIEW_PACK_START,
|
||||
outcome: self::OUTCOME_BLOCK,
|
||||
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.',
|
||||
substrateDecision: $substrateDecision,
|
||||
),
|
||||
default => $this->decision(
|
||||
lifecycle: $lifecycle,
|
||||
actionKey: self::ACTION_REVIEW_PACK_START,
|
||||
outcome: self::OUTCOME_ALLOW,
|
||||
reasonFamily: null,
|
||||
message: 'Review-pack starts are available for this workspace commercial state.',
|
||||
substrateDecision: $substrateDecision,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lifecycle
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function readOnlyDecision(string $actionKey, array $lifecycle): array
|
||||
{
|
||||
if (($lifecycle['state'] ?? null) === self::STATE_SUSPENDED_READ_ONLY) {
|
||||
return $this->decision(
|
||||
lifecycle: $lifecycle,
|
||||
actionKey: $actionKey,
|
||||
outcome: self::OUTCOME_ALLOW_READ_ONLY,
|
||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
||||
message: 'Suspended / read-only workspaces keep existing review, evidence, and generated-pack consumption available under current RBAC.',
|
||||
substrateDecision: null,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->decision(
|
||||
lifecycle: $lifecycle,
|
||||
actionKey: $actionKey,
|
||||
outcome: self::OUTCOME_ALLOW,
|
||||
reasonFamily: null,
|
||||
message: 'Read-only history remains available under current RBAC.',
|
||||
substrateDecision: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lifecycle
|
||||
* @param array<string, mixed>|null $substrateDecision
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decision(
|
||||
array $lifecycle,
|
||||
string $actionKey,
|
||||
string $outcome,
|
||||
?string $reasonFamily,
|
||||
string $message,
|
||||
?array $substrateDecision,
|
||||
): array {
|
||||
return [
|
||||
'workspace_id' => (int) ($lifecycle['workspace_id'] ?? 0),
|
||||
'action_key' => $actionKey,
|
||||
'outcome' => $outcome,
|
||||
'is_blocked' => $outcome === self::OUTCOME_BLOCK,
|
||||
'is_warning' => $outcome === self::OUTCOME_WARN,
|
||||
'block_reason' => $outcome === self::OUTCOME_BLOCK ? $message : null,
|
||||
'warning_reason' => $outcome === self::OUTCOME_WARN ? $message : null,
|
||||
'message' => $message,
|
||||
'reason_family' => $reasonFamily,
|
||||
'state' => (string) $lifecycle['state'],
|
||||
'state_label' => (string) $lifecycle['state_label'],
|
||||
'source' => (string) $lifecycle['source'],
|
||||
'source_label' => (string) $lifecycle['source_label'],
|
||||
'rationale' => $lifecycle['rationale'] ?? null,
|
||||
'entitlement_decision' => $substrateDecision,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
|
||||
*/
|
||||
private function lastChangedMetadata(Workspace $workspace): array
|
||||
{
|
||||
$audit = AuditLog::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('action', AuditActionId::WorkspaceSettingUpdated->value)
|
||||
->where('resource_type', 'workspace_setting')
|
||||
->where('resource_id', self::SETTING_DOMAIN.'.'.self::SETTING_COMMERCIAL_LIFECYCLE_STATE)
|
||||
->latest('recorded_at')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($audit instanceof AuditLog) {
|
||||
return [
|
||||
'last_changed_at' => $audit->recorded_at,
|
||||
'last_changed_by' => $audit->actorDisplayLabel(),
|
||||
];
|
||||
}
|
||||
|
||||
$record = WorkspaceSetting::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('domain', self::SETTING_DOMAIN)
|
||||
->whereIn('key', [
|
||||
self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
||||
self::SETTING_COMMERCIAL_LIFECYCLE_REASON,
|
||||
])
|
||||
->with('updatedByUser:id,name')
|
||||
->latest('updated_at')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if (! $record instanceof WorkspaceSetting) {
|
||||
return [
|
||||
'last_changed_at' => null,
|
||||
'last_changed_by' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'last_changed_at' => $record->updated_at,
|
||||
'last_changed_by' => $record->updatedByUser?->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,327 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Entitlements;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use Carbon\CarbonInterface;
|
||||
|
||||
final class WorkspaceEntitlementResolver
|
||||
{
|
||||
public const SETTING_DOMAIN = 'entitlements';
|
||||
|
||||
public const SETTING_PLAN_PROFILE = 'plan_profile';
|
||||
|
||||
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE = 'managed_tenant_limit_override_value';
|
||||
|
||||
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON = 'managed_tenant_limit_override_reason';
|
||||
|
||||
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE = 'review_pack_generation_override_value';
|
||||
|
||||
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON = 'review_pack_generation_override_reason';
|
||||
|
||||
public const KEY_MANAGED_TENANT_ACTIVATION_LIMIT = 'managed_tenant_activation_limit';
|
||||
|
||||
public const KEY_REVIEW_PACK_GENERATION_ENABLED = 'review_pack_generation_enabled';
|
||||
|
||||
public function __construct(
|
||||
private SettingsResolver $settingsResolver,
|
||||
private WorkspacePlanProfileCatalog $planProfileCatalog,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* plan_profile: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
|
||||
* decisions: array<string, array{
|
||||
* workspace_id: int,
|
||||
* plan_profile_id: string,
|
||||
* plan_profile_label: string,
|
||||
* plan_profile_description: string,
|
||||
* key: string,
|
||||
* effective_value: int|bool,
|
||||
* source: 'plan_profile_default'|'workspace_override',
|
||||
* rationale: string|null,
|
||||
* current_usage: int|null,
|
||||
* remaining_capacity: int|null,
|
||||
* is_blocked: bool,
|
||||
* block_reason: string|null,
|
||||
* last_changed_at: CarbonInterface|null,
|
||||
* last_changed_by: string|null
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
public function summary(Workspace $workspace): array
|
||||
{
|
||||
$planProfile = $this->resolvePlanProfile($workspace);
|
||||
|
||||
return [
|
||||
'plan_profile' => $planProfile,
|
||||
'decisions' => [
|
||||
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolve($workspace, self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, $planProfile),
|
||||
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolve($workspace, self::KEY_REVIEW_PACK_GENERATION_ENABLED, $planProfile),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
||||
*/
|
||||
public function resolvePlanProfile(Workspace $workspace): array
|
||||
{
|
||||
$planProfileId = $this->settingsResolver->resolveValue(
|
||||
workspace: $workspace,
|
||||
domain: self::SETTING_DOMAIN,
|
||||
key: self::SETTING_PLAN_PROFILE,
|
||||
);
|
||||
|
||||
return $this->planProfileCatalog->resolve(is_string($planProfileId) ? $planProfileId : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null $planProfile
|
||||
* @return array{
|
||||
* workspace_id: int,
|
||||
* plan_profile_id: string,
|
||||
* plan_profile_label: string,
|
||||
* plan_profile_description: string,
|
||||
* key: string,
|
||||
* effective_value: int|bool,
|
||||
* source: 'plan_profile_default'|'workspace_override',
|
||||
* rationale: string|null,
|
||||
* current_usage: int|null,
|
||||
* remaining_capacity: int|null,
|
||||
* is_blocked: bool,
|
||||
* block_reason: string|null,
|
||||
* last_changed_at: CarbonInterface|null,
|
||||
* last_changed_by: string|null
|
||||
* }
|
||||
*/
|
||||
public function resolve(Workspace $workspace, string $key, ?array $planProfile = null): array
|
||||
{
|
||||
$planProfile ??= $this->resolvePlanProfile($workspace);
|
||||
$lastChanged = $this->lastChangedMetadata($workspace);
|
||||
|
||||
return match ($key) {
|
||||
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolveManagedTenantActivationLimitDecision($workspace, $planProfile, $lastChanged),
|
||||
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolveReviewPackGenerationDecision($workspace, $planProfile, $lastChanged),
|
||||
default => throw new \InvalidArgumentException(sprintf('Unknown workspace entitlement key: %s', $key)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
||||
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
|
||||
* @return array{
|
||||
* workspace_id: int,
|
||||
* plan_profile_id: string,
|
||||
* plan_profile_label: string,
|
||||
* plan_profile_description: string,
|
||||
* key: string,
|
||||
* effective_value: int,
|
||||
* source: 'plan_profile_default'|'workspace_override',
|
||||
* rationale: string|null,
|
||||
* current_usage: int,
|
||||
* remaining_capacity: int,
|
||||
* is_blocked: bool,
|
||||
* block_reason: string|null,
|
||||
* last_changed_at: CarbonInterface|null,
|
||||
* last_changed_by: string|null
|
||||
* }
|
||||
*/
|
||||
private function resolveManagedTenantActivationLimitDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
|
||||
{
|
||||
$overrideValue = $this->settingsResolver->resolveDetailed(
|
||||
workspace: $workspace,
|
||||
domain: self::SETTING_DOMAIN,
|
||||
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
|
||||
);
|
||||
|
||||
$overrideReason = $this->settingsResolver->resolveValue(
|
||||
workspace: $workspace,
|
||||
domain: self::SETTING_DOMAIN,
|
||||
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
|
||||
);
|
||||
|
||||
$effectiveValue = is_int($overrideValue['value'])
|
||||
? $overrideValue['value']
|
||||
: (int) $planProfile['managed_tenant_limit_default'];
|
||||
|
||||
$source = $overrideValue['source'] === 'workspace_override'
|
||||
? 'workspace_override'
|
||||
: 'plan_profile_default';
|
||||
|
||||
$currentUsage = Tenant::activeQuery()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->count();
|
||||
|
||||
$remainingCapacity = $effectiveValue - $currentUsage;
|
||||
$isBlocked = $currentUsage >= $effectiveValue;
|
||||
$rationale = $source === 'workspace_override'
|
||||
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
|
||||
: (string) $planProfile['description'];
|
||||
|
||||
return [
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'plan_profile_id' => (string) $planProfile['id'],
|
||||
'plan_profile_label' => (string) $planProfile['label'],
|
||||
'plan_profile_description' => (string) $planProfile['description'],
|
||||
'key' => self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
||||
'effective_value' => $effectiveValue,
|
||||
'source' => $source,
|
||||
'rationale' => $rationale,
|
||||
'current_usage' => $currentUsage,
|
||||
'remaining_capacity' => $remainingCapacity,
|
||||
'is_blocked' => $isBlocked,
|
||||
'block_reason' => $isBlocked
|
||||
? $this->managedTenantLimitBlockReason($currentUsage, $effectiveValue, $source, $planProfile, $rationale)
|
||||
: null,
|
||||
'last_changed_at' => $lastChanged['last_changed_at'],
|
||||
'last_changed_by' => $lastChanged['last_changed_by'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
||||
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
|
||||
* @return array{
|
||||
* workspace_id: int,
|
||||
* plan_profile_id: string,
|
||||
* plan_profile_label: string,
|
||||
* plan_profile_description: string,
|
||||
* key: string,
|
||||
* effective_value: bool,
|
||||
* source: 'plan_profile_default'|'workspace_override',
|
||||
* rationale: string|null,
|
||||
* current_usage: null,
|
||||
* remaining_capacity: null,
|
||||
* is_blocked: bool,
|
||||
* block_reason: string|null,
|
||||
* last_changed_at: CarbonInterface|null,
|
||||
* last_changed_by: string|null
|
||||
* }
|
||||
*/
|
||||
private function resolveReviewPackGenerationDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
|
||||
{
|
||||
$overrideValue = $this->settingsResolver->resolveDetailed(
|
||||
workspace: $workspace,
|
||||
domain: self::SETTING_DOMAIN,
|
||||
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
|
||||
);
|
||||
|
||||
$overrideReason = $this->settingsResolver->resolveValue(
|
||||
workspace: $workspace,
|
||||
domain: self::SETTING_DOMAIN,
|
||||
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
|
||||
);
|
||||
|
||||
$effectiveValue = is_bool($overrideValue['value'])
|
||||
? $overrideValue['value']
|
||||
: (bool) $planProfile['review_pack_generation_default'];
|
||||
|
||||
$source = $overrideValue['source'] === 'workspace_override'
|
||||
? 'workspace_override'
|
||||
: 'plan_profile_default';
|
||||
|
||||
$rationale = $source === 'workspace_override'
|
||||
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
|
||||
: (string) $planProfile['description'];
|
||||
|
||||
return [
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'plan_profile_id' => (string) $planProfile['id'],
|
||||
'plan_profile_label' => (string) $planProfile['label'],
|
||||
'plan_profile_description' => (string) $planProfile['description'],
|
||||
'key' => self::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
||||
'effective_value' => $effectiveValue,
|
||||
'source' => $source,
|
||||
'rationale' => $rationale,
|
||||
'current_usage' => null,
|
||||
'remaining_capacity' => null,
|
||||
'is_blocked' => ! $effectiveValue,
|
||||
'block_reason' => $effectiveValue
|
||||
? null
|
||||
: $this->reviewPackGenerationBlockReason($source, $planProfile, $rationale),
|
||||
'last_changed_at' => $lastChanged['last_changed_at'],
|
||||
'last_changed_by' => $lastChanged['last_changed_by'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
|
||||
*/
|
||||
private function lastChangedMetadata(Workspace $workspace): array
|
||||
{
|
||||
$record = WorkspaceSetting::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('domain', self::SETTING_DOMAIN)
|
||||
->whereIn('key', [
|
||||
self::SETTING_PLAN_PROFILE,
|
||||
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
|
||||
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
|
||||
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
|
||||
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
|
||||
])
|
||||
->whereNotNull('updated_by_user_id')
|
||||
->with('updatedByUser:id,name')
|
||||
->latest('updated_at')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if (! $record instanceof WorkspaceSetting) {
|
||||
return [
|
||||
'last_changed_at' => null,
|
||||
'last_changed_by' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'last_changed_at' => $record->updated_at,
|
||||
'last_changed_by' => $record->updatedByUser?->name,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
||||
*/
|
||||
private function managedTenantLimitBlockReason(int $currentUsage, int $effectiveValue, string $source, array $planProfile, ?string $rationale): string
|
||||
{
|
||||
$prefix = $source === 'workspace_override'
|
||||
? 'This workspace override currently allows'
|
||||
: sprintf('The %s plan profile currently allows', $planProfile['label']);
|
||||
|
||||
$message = sprintf(
|
||||
'%s %d active managed tenant%s, and this workspace already has %d active managed tenant%s.',
|
||||
$prefix,
|
||||
$effectiveValue,
|
||||
$effectiveValue === 1 ? '' : 's',
|
||||
$currentUsage,
|
||||
$currentUsage === 1 ? '' : 's',
|
||||
);
|
||||
|
||||
if ($source === 'workspace_override' && $rationale !== null) {
|
||||
$message .= ' Reason: '.$rationale;
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
||||
*/
|
||||
private function reviewPackGenerationBlockReason(string $source, array $planProfile, ?string $rationale): string
|
||||
{
|
||||
$message = $source === 'workspace_override'
|
||||
? 'Review pack generation is disabled by workspace override.'
|
||||
: sprintf('Review pack generation is disabled by the %s plan profile.', $planProfile['label']);
|
||||
|
||||
if ($source === 'workspace_override' && $rationale !== null) {
|
||||
$message .= ' Reason: '.$rationale;
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
@ -1,104 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Entitlements;
|
||||
|
||||
final class WorkspacePlanProfileCatalog
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
|
||||
*/
|
||||
private const PROFILES = [
|
||||
'starter' => [
|
||||
'id' => 'starter',
|
||||
'label' => 'Starter',
|
||||
'description' => 'Minimal allowance for early workspace access and low-volume operations.',
|
||||
'managed_tenant_limit_default' => 1,
|
||||
'review_pack_generation_default' => false,
|
||||
'is_default' => false,
|
||||
],
|
||||
'standard' => [
|
||||
'id' => 'standard',
|
||||
'label' => 'Standard',
|
||||
'description' => 'Balanced defaults for most managed workspaces.',
|
||||
'managed_tenant_limit_default' => 25,
|
||||
'review_pack_generation_default' => true,
|
||||
'is_default' => true,
|
||||
],
|
||||
'scale' => [
|
||||
'id' => 'scale',
|
||||
'label' => 'Scale',
|
||||
'description' => 'Higher managed-tenant capacity for larger workspace portfolios.',
|
||||
'managed_tenant_limit_default' => 100,
|
||||
'review_pack_generation_default' => true,
|
||||
'is_default' => false,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @return list<array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return array_values(self::PROFILES);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
||||
*/
|
||||
public function default(): array
|
||||
{
|
||||
return self::PROFILES[self::defaultProfileId()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null
|
||||
*/
|
||||
public function find(?string $id): ?array
|
||||
{
|
||||
if ($id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::PROFILES[$id] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
||||
*/
|
||||
public function resolve(?string $id): array
|
||||
{
|
||||
return $this->find($id) ?? $this->default();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function optionLabels(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (array $profile): string => $profile['label'],
|
||||
self::PROFILES,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function profileIds(): array
|
||||
{
|
||||
return array_keys(self::PROFILES);
|
||||
}
|
||||
|
||||
public static function defaultProfileId(): string
|
||||
{
|
||||
foreach (self::PROFILES as $id => $profile) {
|
||||
if ($profile['is_default']) {
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Workspace plan profile catalog is missing a default profile.');
|
||||
}
|
||||
}
|
||||
@ -163,7 +163,7 @@ private function upsertFinding(
|
||||
->first();
|
||||
|
||||
if ($existing instanceof Finding) {
|
||||
$this->observeFinding($existing, $tenant, $observedAt, $severity);
|
||||
$this->observeFinding($existing, $observedAt);
|
||||
|
||||
$existing->forceFill([
|
||||
'severity' => $severity,
|
||||
@ -253,7 +253,7 @@ private function handleGaAggregate(
|
||||
->first();
|
||||
|
||||
if ($existing instanceof Finding) {
|
||||
$this->observeFinding($existing, $tenant, $observedAt, Finding::SEVERITY_HIGH);
|
||||
$this->observeFinding($existing, $observedAt);
|
||||
|
||||
$existing->forceFill([
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
@ -380,33 +380,25 @@ private function resolveSlaPolicy(): FindingSlaPolicy
|
||||
return $this->slaPolicy ?? app(FindingSlaPolicy::class);
|
||||
}
|
||||
|
||||
private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): void
|
||||
private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void
|
||||
{
|
||||
if ($finding->first_seen_at === null) {
|
||||
$finding->first_seen_at = $observedAt;
|
||||
}
|
||||
|
||||
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
|
||||
|
||||
$lastSeenAt = $finding->last_seen_at;
|
||||
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
||||
|
||||
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
|
||||
$finding->last_seen_at = $observedAt;
|
||||
$finding->times_seen = max(0, $timesSeen) + 1;
|
||||
} elseif ($timesSeen < 1) {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($timesSeen < 1) {
|
||||
$finding->times_seen = 1;
|
||||
}
|
||||
|
||||
$slaPolicy = $this->resolveSlaPolicy();
|
||||
|
||||
if ($finding->sla_days === null) {
|
||||
$finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant);
|
||||
}
|
||||
|
||||
if ($finding->due_at === null) {
|
||||
$finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
|
||||
}
|
||||
}
|
||||
|
||||
private function produceAlertEvent(Tenant $tenant, string $fingerprint, array $evidence): void
|
||||
|
||||
@ -9,8 +9,6 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
|
||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||
use Carbon\CarbonImmutable;
|
||||
use RuntimeException;
|
||||
|
||||
@ -20,7 +18,6 @@ public function __construct(
|
||||
private readonly GraphClientInterface $graphClient,
|
||||
private readonly HighPrivilegeRoleCatalog $catalog,
|
||||
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
||||
private readonly ProductTelemetryRecorder $productTelemetryRecorder,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -60,8 +57,6 @@ public function generate(Tenant $tenant, ?OperationRun $operationRun = null): En
|
||||
'previous_fingerprint' => $latestReport?->fingerprint,
|
||||
]);
|
||||
|
||||
$this->recordStoredReportTelemetry($report, $operationRun);
|
||||
|
||||
return new EntraAdminRolesReportResult(
|
||||
created: true,
|
||||
storedReportId: (int) $report->getKey(),
|
||||
@ -197,24 +192,4 @@ private function resolvePrincipalType(array $principal): string
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
private function recordStoredReportTelemetry(StoredReport $report, ?OperationRun $operationRun): void
|
||||
{
|
||||
if (! $operationRun instanceof OperationRun || ! is_numeric($operationRun->user_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->productTelemetryRecorder->record(
|
||||
eventName: ProductUsageEventCatalog::STORED_REPORT_CREATED,
|
||||
workspaceId: (int) $report->workspace_id,
|
||||
tenantId: (int) $report->tenant_id,
|
||||
userId: (int) $operationRun->user_id,
|
||||
subjectType: 'stored_report',
|
||||
subjectId: (int) $report->getKey(),
|
||||
metadata: [
|
||||
'report_type' => $report->report_type,
|
||||
],
|
||||
occurredAt: $report->created_at ?? now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,7 +44,6 @@ public function collect(Tenant $tenant): array
|
||||
'entries' => $runs->map(static fn (OperationRun $run): array => [
|
||||
'id' => (int) $run->getKey(),
|
||||
'type' => (string) $run->type,
|
||||
'operation_type' => $run->canonicalOperationType(),
|
||||
'status' => (string) $run->status,
|
||||
'outcome' => (string) $run->outcome,
|
||||
'initiator_name' => $run->user?->name,
|
||||
|
||||
@ -46,13 +46,17 @@ public static function meaningfulActivityActionValues(): array
|
||||
|
||||
public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||
{
|
||||
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);
|
||||
$this->authorize($finding, $tenant, $actor, [
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
]);
|
||||
|
||||
$currentStatus = (string) $finding->status;
|
||||
|
||||
if (! in_array($currentStatus, [
|
||||
Finding::STATUS_NEW,
|
||||
Finding::STATUS_REOPENED,
|
||||
Finding::STATUS_ACKNOWLEDGED,
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Finding cannot be triaged from the current status.');
|
||||
}
|
||||
@ -78,9 +82,12 @@ public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||
|
||||
public function startProgress(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||
{
|
||||
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);
|
||||
$this->authorize($finding, $tenant, $actor, [
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
]);
|
||||
|
||||
if ((string) $finding->status !== Finding::STATUS_TRIAGED) {
|
||||
if (! in_array((string) $finding->status, [Finding::STATUS_TRIAGED, Finding::STATUS_ACKNOWLEDGED], true)) {
|
||||
throw new InvalidArgumentException('Finding cannot be moved to in-progress from the current status.');
|
||||
}
|
||||
|
||||
@ -362,7 +369,10 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant
|
||||
|
||||
public function reopen(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding
|
||||
{
|
||||
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);
|
||||
$this->authorize($finding, $tenant, $actor, [
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
]);
|
||||
|
||||
if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) {
|
||||
throw new InvalidArgumentException('Only terminal findings can be reopened.');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user