356 lines
12 KiB
PHP
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;
|
|
}
|
|
}
|