## Summary - add tenant triage review-state persistence, fingerprinting, resolver logic, service layer, and migration for current affected-set tracking - surface review-state and affected-set progress across tenant registry, tenant dashboard arrival continuity, and workspace overview - extend RBAC, audit/badge support, specs, and test coverage for portfolio triage review-state workflows - suppress expected hidden-page background transport failures in the global unhandled rejection logger while keeping visible-page failures logged ## Validation - targeted Pest coverage added for tenant registry, workspace overview, arrival context, RBAC authorization, badges, fingerprinting, resolver behavior, and logger asset behavior - code formatted with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` ## Notes - full suite was not re-run in this final step - branch includes the spec artifacts under `specs/189-portfolio-triage-review-state/` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #220
268 lines
9.5 KiB
PHP
268 lines
9.5 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;
|
|
|
|
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;
|
|
}
|
|
|
|
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
|
|
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
|
$actor = auth()->user();
|
|
|
|
$review = match ($targetManualState) {
|
|
TenantTriageReview::STATE_REVIEWED => $service->markReviewed(
|
|
tenant: $tenant,
|
|
concernFamily: $context->concernFamily,
|
|
backupHealth: $backupHealth,
|
|
recoveryEvidence: $recoveryEvidence,
|
|
actor: $actor instanceof User ? $actor : null,
|
|
),
|
|
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $service->markFollowUpNeeded(
|
|
tenant: $tenant,
|
|
concernFamily: $context->concernFamily,
|
|
backupHealth: $backupHealth,
|
|
recoveryEvidence: $recoveryEvidence,
|
|
actor: $actor instanceof User ? $actor : null,
|
|
),
|
|
default => null,
|
|
};
|
|
|
|
if (! $review instanceof TenantTriageReview) {
|
|
return;
|
|
}
|
|
|
|
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
|
|
{
|
|
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
|
|
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
|
|
|
return app(TenantTriageReviewStateResolver::class)->resolveMany(
|
|
workspaceId: (int) $tenant->workspace_id,
|
|
tenantIds: [(int) $tenant->getKey()],
|
|
backupHealthByTenant: [(int) $tenant->getKey() => $backupHealth],
|
|
recoveryEvidenceByTenant: [(int) $tenant->getKey() => $recoveryEvidence],
|
|
)['rows'][(int) $tenant->getKey()][$concernFamily] ?? null;
|
|
}
|
|
|
|
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
|
|
{
|
|
return app(PortfolioArrivalContextResolver::class)->resolveState($tenant, $this->arrivalState);
|
|
}
|
|
}
|