TenantAtlas/apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php
ahmido acc8947384 feat: harden governance action semantics (#229)
## Summary
- add the Spec 194 governance action catalog, friction classes, reason policies, and regression guards
- align exception, review, evidence, finding, tenant, provider connection, and system run actions to the shared semantics model
- add focused feature, RBAC, audit, unit, and browser coverage, including the tenant detail triage header consistency update

## Verification
- ran the focused Spec 194 verification pack from the quickstart and task plan
- ran targeted tenant triage coverage after the detail-header update
- ran `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

## Filament Notes
- Filament v5 / Livewire v4 compliance preserved
- provider registration remains in `apps/platform/bootstrap/providers.php`
- globally searchable resources were not changed
- destructive actions remain confirmation-gated and server-authorized
- no new Filament assets were introduced; the existing `cd apps/platform && php artisan filament:assets` deploy step stays unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #229
2026-04-12 21:21:44 +00:00

356 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Tenant;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Services\PortfolioTriage\TenantTriageReviewService;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\PortfolioTriage\PortfolioArrivalContext;
use App\Support\PortfolioTriage\PortfolioArrivalContextResolver;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Widgets\Widget;
class TenantTriageArrivalContinuity extends Widget implements HasActions, HasSchemas
{
use InteractsWithActions;
use InteractsWithSchemas;
/**
* @var array<string, mixed>|null
*/
public ?array $arrivalState = null;
private ?PortfolioArrivalContext $cachedArrivalContext = null;
private ?int $cachedArrivalContextTenantId = null;
private bool $hasCachedArrivalContext = false;
/**
* @var array{backupHealth: \App\Support\BackupHealth\TenantBackupHealthAssessment, recoveryEvidence: array<string, mixed>}|null
*/
private ?array $cachedConcernTruth = null;
private ?int $cachedConcernTruthTenantId = null;
/**
* @var array<int, array<string, array<string, mixed>|null>>
*/
private array $cachedReviewStates = [];
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
protected string $view = 'filament.widgets.tenant.triage-arrival-continuity';
public function mount(): void
{
$this->arrivalState = PortfolioArrivalContextToken::decode(
request()->query(PortfolioArrivalContextToken::QUERY_PARAMETER),
);
}
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return ['context' => null, 'reviewState' => null];
}
$context = $this->resolveArrivalContext($tenant);
if ($context === null) {
return ['context' => null, 'reviewState' => null];
}
return [
'context' => $context,
'reviewState' => $this->currentReviewStateFor($tenant, $context->concernFamily),
];
}
public function markReviewedAction(): Action
{
return UiEnforcement::forAction(
Action::make('markReviewed')
->label('Mark reviewed')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->modalHeading('Mark reviewed')
->modalDescription($this->reviewModalDescription(TenantTriageReview::STATE_REVIEWED))
->visible(fn (): bool => $this->canShowReviewActions())
->action(function (TenantTriageReviewService $service): void {
$this->handleReviewMutation(TenantTriageReview::STATE_REVIEWED, $service);
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_TRIAGE_REVIEW_MANAGE)
->apply();
}
public function markFollowUpNeededAction(): Action
{
return UiEnforcement::forAction(
Action::make('markFollowUpNeeded')
->label('Mark follow-up needed')
->icon('heroicon-o-exclamation-triangle')
->color('warning')
->requiresConfirmation()
->modalHeading('Mark follow-up needed')
->modalDescription($this->reviewModalDescription(TenantTriageReview::STATE_FOLLOW_UP_NEEDED))
->visible(fn (): bool => $this->canShowReviewActions())
->action(function (TenantTriageReviewService $service): void {
$this->handleReviewMutation(TenantTriageReview::STATE_FOLLOW_UP_NEEDED, $service);
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_TRIAGE_REVIEW_MANAGE)
->apply();
}
private function canShowReviewActions(): bool
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return false;
}
$context = $this->resolveArrivalContext($tenant);
if ($context === null) {
return false;
}
return ($this->currentReviewStateFor($tenant, $context->concernFamily)['current_concern_present'] ?? false) === true;
}
private function reviewModalDescription(string $targetManualState): \Closure
{
return function () use ($targetManualState): string {
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return 'This triage session is no longer available.';
}
$context = $this->resolveArrivalContext($tenant);
if ($context === null) {
return 'This triage session is no longer available.';
}
$reviewState = $this->currentReviewStateFor($tenant, $context->concernFamily);
if (($reviewState['current_concern_present'] ?? false) !== true) {
return 'This triage session no longer points at a current concern.';
}
$currentLabel = BadgeRenderer::spec(
BadgeDomain::TenantTriageReviewState,
(string) ($reviewState['derived_state'] ?? TenantTriageReview::DERIVED_STATE_NOT_REVIEWED),
)->label;
$targetLabel = BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $targetManualState)->label;
return implode("\n\n", [
'Concern family: '.$this->concernFamilyLabel($context->concernFamily),
'Current review state: '.$currentLabel,
'Target state: '.$targetLabel,
'Scope: TenantPilot only. This updates shared triage progress and does not change backup posture or recovery evidence.',
]);
};
}
private function handleReviewMutation(string $targetManualState, TenantTriageReviewService $service): void
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return;
}
$context = $this->resolveArrivalContext($tenant);
if ($context === null) {
Notification::make()
->title('No triage session available')
->warning()
->send();
return;
}
$reviewState = $this->currentReviewStateFor($tenant, $context->concernFamily);
if (($reviewState['current_concern_present'] ?? false) !== true) {
Notification::make()
->title('No current concern to update')
->body('This arrival context no longer maps to an active concern.')
->warning()
->send();
return;
}
$concernTruth = $this->concernTruthFor($tenant);
$actor = auth()->user();
$review = match ($targetManualState) {
TenantTriageReview::STATE_REVIEWED => $service->markReviewed(
tenant: $tenant,
concernFamily: $context->concernFamily,
backupHealth: $concernTruth['backupHealth'],
recoveryEvidence: $concernTruth['recoveryEvidence'],
actor: $actor instanceof User ? $actor : null,
),
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $service->markFollowUpNeeded(
tenant: $tenant,
concernFamily: $context->concernFamily,
backupHealth: $concernTruth['backupHealth'],
recoveryEvidence: $concernTruth['recoveryEvidence'],
actor: $actor instanceof User ? $actor : null,
),
default => null,
};
if (! $review instanceof TenantTriageReview) {
return;
}
$this->clearConcernCachesFor($tenant);
Notification::make()
->title('Review state updated')
->body(sprintf(
'%s is now %s for %s.',
$tenant->name,
BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $review->current_state)->label,
$this->concernFamilyLabel($context->concernFamily),
))
->success()
->send();
}
/**
* @return array<string, mixed>|null
*/
private function currentReviewStateFor(Tenant $tenant, string $concernFamily): ?array
{
$tenantId = (int) $tenant->getKey();
if (array_key_exists($tenantId, $this->cachedReviewStates)
&& array_key_exists($concernFamily, $this->cachedReviewStates[$tenantId])) {
return $this->cachedReviewStates[$tenantId][$concernFamily];
}
$concernTruth = $this->concernTruthFor($tenant);
$reviewState = app(TenantTriageReviewStateResolver::class)->resolveMany(
workspaceId: (int) $tenant->workspace_id,
tenantIds: [$tenantId],
backupHealthByTenant: [$tenantId => $concernTruth['backupHealth']],
recoveryEvidenceByTenant: [$tenantId => $concernTruth['recoveryEvidence']],
)['rows'][$tenantId][$concernFamily] ?? null;
$this->cachedReviewStates[$tenantId][$concernFamily] = $reviewState;
return $reviewState;
}
/**
* @return array{backupHealth: \App\Support\BackupHealth\TenantBackupHealthAssessment, recoveryEvidence: array<string, mixed>}
*/
private function concernTruthFor(Tenant $tenant): array
{
$tenantId = (int) $tenant->getKey();
if ($this->cachedConcernTruthTenantId === $tenantId && is_array($this->cachedConcernTruth)) {
return $this->cachedConcernTruth;
}
$this->cachedConcernTruthTenantId = $tenantId;
$this->cachedConcernTruth = [
'backupHealth' => app(TenantBackupHealthResolver::class)->assess($tenant),
'recoveryEvidence' => app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant),
];
return $this->cachedConcernTruth;
}
private function clearConcernCachesFor(Tenant $tenant): void
{
$tenantId = (int) $tenant->getKey();
if ($this->cachedConcernTruthTenantId === $tenantId) {
$this->cachedConcernTruthTenantId = null;
$this->cachedConcernTruth = null;
}
if (array_key_exists($tenantId, $this->cachedReviewStates)) {
unset($this->cachedReviewStates[$tenantId]);
}
}
private function concernFamilyLabel(string $concernFamily): string
{
return match ($concernFamily) {
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => 'Backup health',
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => 'Recovery evidence',
default => 'Portfolio concern',
};
}
private function resolveArrivalContext(Tenant $tenant): ?PortfolioArrivalContext
{
$tenantId = (int) $tenant->getKey();
if ($this->arrivalState === null) {
$this->cachedArrivalContextTenantId = $tenantId;
$this->cachedArrivalContext = null;
$this->hasCachedArrivalContext = true;
return null;
}
if ($this->hasCachedArrivalContext && $this->cachedArrivalContextTenantId === $tenantId) {
return $this->cachedArrivalContext;
}
$concernTruth = $this->concernTruthFor($tenant);
$this->cachedArrivalContextTenantId = $tenantId;
$this->cachedArrivalContext = app(PortfolioArrivalContextResolver::class)->resolveStateWithTruth(
$tenant,
$this->arrivalState,
$concernTruth['backupHealth'],
$concernTruth['recoveryEvidence'],
);
$this->hasCachedArrivalContext = true;
return $this->cachedArrivalContext;
}
}