TenantAtlas/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php

787 lines
29 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Pages\Monitoring;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Findings\FindingExceptionService;
use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
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\GovernanceActions\GovernanceActionCatalog;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Facades\Filament;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
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\Collection;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class FindingExceptionsQueue extends Page implements HasTable
{
use InteractsWithTable;
protected const MONITORING_PAGE_STATE_CONTRACT = [
'surfaceKey' => 'finding_exceptions_queue',
'surfaceType' => 'selected_record_monitoring',
'stateFields' => [
[
'stateKey' => 'exception',
'stateClass' => 'inspect',
'carrier' => 'query_param',
'queryRole' => 'durable_restorable',
'shareable' => true,
'restorableOnRefresh' => true,
'tenantSensitive' => false,
'invalidFallback' => 'clear_selection_and_continue',
],
[
'stateKey' => 'tenant',
'stateClass' => 'contextual_prefilter',
'carrier' => 'query_param',
'queryRole' => 'durable_restorable',
'shareable' => true,
'restorableOnRefresh' => true,
'tenantSensitive' => true,
'invalidFallback' => 'discard_and_continue',
],
[
'stateKey' => 'tableFilters',
'stateClass' => 'shareable_restorable',
'carrier' => 'session',
'queryRole' => 'unsupported',
'shareable' => false,
'restorableOnRefresh' => true,
'tenantSensitive' => true,
'invalidFallback' => 'discard_and_continue',
],
[
'stateKey' => 'tableSearch',
'stateClass' => 'shareable_restorable',
'carrier' => 'session',
'queryRole' => 'unsupported',
'shareable' => false,
'restorableOnRefresh' => true,
'tenantSensitive' => false,
'invalidFallback' => 'discard_and_continue',
],
],
'hydrationRule' => [
'precedenceOrder' => ['query', 'session', 'default'],
'appliesOnInitialMountOnly' => true,
'activeStateBecomesAuthoritativeAfterMount' => true,
'clearsOnTenantSwitch' => ['tenant', 'tenant_id', 'status', 'current_validity_state'],
'invalidRequestedStateFallback' => 'clear_selection_and_continue',
],
'inspectContract' => [
'primaryModel' => FindingException::class,
'selectedStateKey' => 'selectedFindingExceptionId',
'openedBy' => ['query_param', 'inspect_action'],
'presentation' => 'summary_plus_related_actions',
'shareable' => true,
'invalidSelectionFallback' => 'clear_selection_and_continue',
],
'shareableStateKeys' => ['tenant', 'exception'],
'localOnlyStateKeys' => [],
];
public ?int $selectedFindingExceptionId = null;
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Finding exceptions';
protected static ?string $slug = 'finding-exceptions/queue';
protected static ?string $title = 'Finding Exceptions Queue';
protected string $view = 'filament.pages.monitoring.finding-exceptions-queue';
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::QueueReview)
->withDefaults(new ActionSurfaceDefaults(
moreGroupLabel: 'More',
exportIsDefaultBulkActionForReadOnly: false,
))
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep workspace approval scope visible and expose selected exception review actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions are reviewed one record at a time in v1 and do not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains when the approval queue is empty and keeps navigation back to tenant findings available.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected exception detail exposes approve, reject, and related-record navigation actions in the page header.');
}
/**
* @return array<string, mixed>
*/
public static function monitoringPageStateContract(): array
{
return self::MONITORING_PAGE_STATE_CONTRACT;
}
public static function canAccess(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return false;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
}
public function mount(): void
{
$this->mountInteractsWithTable();
$this->applyRequestedTenantPrefilter();
$requestedExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
if ($requestedExceptionId !== null) {
$this->selectedFindingExceptionId = $this->resolveSelectedFindingExceptionId($requestedExceptionId);
}
}
protected function getHeaderActions(): array
{
$actions = app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_finding_exceptions',
returnActionName: 'operate_hub_return_finding_exceptions',
);
$actions[] = Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActiveQueueFilters())
->action(function (): void {
$this->removeTableFilter('tenant_id');
$this->removeTableFilter('status');
$this->removeTableFilter('current_validity_state');
$this->selectedFindingExceptionId = null;
$this->resetTable();
});
$actions[] = Action::make('view_tenant_register')
->label('View tenant register')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->visible(fn (): bool => $this->filteredTenant() instanceof Tenant)
->url(function (): ?string {
$tenant = $this->filteredTenant();
if (! $tenant instanceof Tenant) {
return null;
}
return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant);
});
$selectedContextActions = [
Action::make('clear_selected_exception')
->label('Close details')
->color('gray')
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
->url(fn (): string => $this->queueUrl(['exception' => null])),
Action::make('open_selected_exception')
->label('Open tenant detail')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
->url(fn (): ?string => $this->selectedExceptionUrl()),
Action::make('open_selected_finding')
->label('Open finding')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
->url(fn (): ?string => $this->selectedFindingUrl()),
];
$selectedDecisionActions = [
Action::make('approve_selected_exception')
->label(GovernanceActionCatalog::rule('approve_exception')->canonicalLabel)
->color('success')
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
->requiresConfirmation()
->modalHeading(GovernanceActionCatalog::rule('approve_exception')->modalHeading)
->modalDescription(GovernanceActionCatalog::rule('approve_exception')->modalDescription)
->form([
DateTimePicker::make('effective_from')
->label('Effective from')
->required()
->seconds(false),
DateTimePicker::make('expires_at')
->label('Expires at')
->required()
->seconds(false),
Textarea::make('approval_reason')
->label('Approval reason')
->rows(3)
->required()
->maxLength(2000),
])
->action(function (array $data, FindingExceptionService $service): void {
$record = $this->selectedFindingException();
$user = auth()->user();
if (! $record instanceof FindingException || ! $user instanceof User) {
abort(404);
}
$wasRenewalRequest = $record->isPendingRenewal();
$updated = $service->approve($record, $user, $data);
$this->selectedFindingExceptionId = (int) $updated->getKey();
$this->resetTable();
Notification::make()
->title($wasRenewalRequest ? 'Exception renewed' : GovernanceActionCatalog::rule('approve_exception')->successTitle)
->success()
->send();
}),
Action::make('reject_selected_exception')
->label(GovernanceActionCatalog::rule('reject_exception')->canonicalLabel)
->color('warning')
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
->requiresConfirmation()
->modalHeading(GovernanceActionCatalog::rule('reject_exception')->modalHeading)
->modalDescription(GovernanceActionCatalog::rule('reject_exception')->modalDescription)
->form([
Textarea::make('rejection_reason')
->label('Rejection reason')
->rows(3)
->required()
->maxLength(2000),
])
->action(function (array $data, FindingExceptionService $service): void {
$record = $this->selectedFindingException();
$user = auth()->user();
if (! $record instanceof FindingException || ! $user instanceof User) {
abort(404);
}
$wasRenewalRequest = $record->isPendingRenewal();
$updated = $service->reject($record, $user, $data);
$this->selectedFindingExceptionId = (int) $updated->getKey();
$this->resetTable();
Notification::make()
->title($wasRenewalRequest ? 'Renewal rejected' : GovernanceActionCatalog::rule('reject_exception')->successTitle)
->success()
->send();
}),
];
$actions[] = ActionGroup::make($selectedContextActions)
->label('Selected context')
->icon('heroicon-o-rectangle-stack')
->color('gray')
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException);
$actions[] = ActionGroup::make($selectedDecisionActions)
->label('Review selected')
->icon('heroicon-o-shield-check')
->color('primary')
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false);
return $actions;
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->queueBaseQuery())
->defaultSort('requested_at', 'asc')
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
TextColumn::make('current_validity_state')
->label('Validity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
TextColumn::make('tenant.name')
->label('Tenant')
->searchable(),
TextColumn::make('finding_summary')
->label('Finding')
->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id)
->searchable(),
TextColumn::make('governance_warning')
->label('Governance warning')
->state(fn (FindingException $record): ?string => $this->governanceWarning($record))
->color(fn (FindingException $record): string => $this->governanceWarningColor($record))
->wrap(),
TextColumn::make('requester.name')
->label('Requested by')
->placeholder('—'),
TextColumn::make('owner.name')
->label('Owner')
->placeholder('—'),
TextColumn::make('review_due_at')
->label('Review due')
->dateTime()
->placeholder('—')
->sortable(),
TextColumn::make('expires_at')
->label('Expires')
->dateTime()
->placeholder('—')
->sortable(),
TextColumn::make('requested_at')
->label('Requested')
->dateTime()
->sortable(),
])
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->tenantFilterOptions())
->searchable(),
SelectFilter::make('status')
->options(FilterOptionCatalog::findingExceptionStatuses()),
SelectFilter::make('current_validity_state')
->label('Validity')
->options(FilterOptionCatalog::findingExceptionValidityStates()),
])
->actions([
Action::make('inspect_exception')
->label('Inspect exception')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (FindingException $record): string => $this->queueUrl(['exception' => (int) $record->getKey()])),
])
->bulkActions([])
->emptyStateHeading('No exceptions match this queue')
->emptyStateDescription('Adjust the current tenant or lifecycle filters to review governed exceptions in this workspace.')
->emptyStateIcon('heroicon-o-shield-check')
->emptyStateActions([
Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->action(function (): void {
$this->removeTableFilter('tenant_id');
$this->removeTableFilter('status');
$this->removeTableFilter('current_validity_state');
$this->selectedFindingExceptionId = null;
$this->resetTable();
}),
]);
}
public function updatedTableFilters(): void
{
$this->normalizeSelectedFindingExceptionId();
}
public function updatedTableSearch(): void
{
$this->normalizeSelectedFindingExceptionId();
}
public function selectedFindingException(): ?FindingException
{
if (! is_int($this->selectedFindingExceptionId)) {
return null;
}
$this->normalizeSelectedFindingExceptionId();
if (! is_int($this->selectedFindingExceptionId)) {
return null;
}
try {
return $this->resolveSelectedFindingException($this->selectedFindingExceptionId);
} catch (NotFoundHttpException) {
return null;
}
}
public function selectedExceptionUrl(): ?string
{
$record = $this->selectedFindingException();
if (! $record instanceof FindingException || ! $record->tenant) {
return null;
}
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant);
}
public function selectedFindingUrl(): ?string
{
$record = $this->selectedFindingException();
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
return null;
}
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
}
public function clearSelectedException(): void
{
$this->selectedFindingExceptionId = null;
}
/**
* @return array<int, Tenant>
*/
public function authorizedTenants(): array
{
if ($this->authorizedTenants !== null) {
return $this->authorizedTenants;
}
$user = auth()->user();
if (! $user instanceof User) {
return $this->authorizedTenants = [];
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return $this->authorizedTenants = [];
}
$tenants = $user->tenants()
->where('tenants.workspace_id', $workspaceId)
->orderBy('tenants.name')
->get();
return $this->authorizedTenants = $tenants
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
->values()
->all();
}
private function queueBaseQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$tenantIds = array_values(array_map(
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
$this->authorizedTenants(),
));
return FindingException::query()
->with([
'tenant',
'requester',
'owner',
'approver',
'finding' => fn ($query) => $query->withSubjectDisplayName(),
'decisions.actor',
'evidenceReferences',
])
->where('workspace_id', (int) $workspaceId)
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds);
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return Collection::make($this->authorizedTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => $tenant->name,
])
->all();
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = 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->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
return;
}
}
private function filteredTenant(): ?Tenant
{
$tenantId = $this->currentTenantFilterId();
if (! is_int($tenantId)) {
return null;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenant;
}
}
return null;
}
private function currentTenantFilterId(): ?int
{
$tenantFilter = app(CanonicalAdminTenantFilterState::class)->currentFilterValue(
$this->getTableFiltersSessionKey(),
$this->tableFilters ?? [],
request(),
);
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
}
private function hasActiveQueueFilters(): bool
{
return $this->currentTenantFilterId() !== null
|| is_string(data_get($this->tableFilters, 'status.value'))
|| is_string(data_get($this->tableFilters, 'current_validity_state.value'));
}
private function resolveSelectedFindingException(int $findingExceptionId): FindingException
{
$record = $this->queueBaseQuery()
->whereKey($findingExceptionId)
->first();
if (! $record instanceof FindingException) {
throw new NotFoundHttpException;
}
return $record;
}
private function queueUrl(array $overrides = []): string
{
$parameters = array_merge(
$this->navigationContext()?->toQuery() ?? [],
[
'tenant' => $this->filteredTenant()?->getKey(),
'exception' => $this->selectedFindingExceptionId,
],
$overrides,
);
return static::getUrl(
panel: 'admin',
parameters: array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []),
);
}
private function navigationContext(): ?CanonicalNavigationContext
{
return CanonicalNavigationContext::fromRequest(request());
}
private function normalizeSelectedFindingExceptionId(): void
{
if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) {
$this->selectedFindingExceptionId = null;
return;
}
$this->selectedFindingExceptionId = $this->resolveSelectedFindingExceptionId($this->selectedFindingExceptionId);
}
private function resolveSelectedFindingExceptionId(int $findingExceptionId): ?int
{
try {
$record = $this->resolveSelectedFindingException($findingExceptionId);
} catch (NotFoundHttpException) {
return null;
}
return $this->selectedFindingExceptionVisible((int) $record->getKey())
? (int) $record->getKey()
: null;
}
private function selectedFindingExceptionVisible(int $findingExceptionId): bool
{
$record = $this->resolveSelectedFindingException($findingExceptionId);
return $this->matchesSelectedFindingExceptionFilters($record)
&& $this->matchesSelectedFindingExceptionSearch($record);
}
/**
* @return array<string, mixed>
*/
private function currentQueueFiltersState(): array
{
$persisted = session()->get($this->getTableFiltersSessionKey(), []);
return array_replace_recursive(
is_array($persisted) ? $persisted : [],
$this->tableFilters ?? [],
);
}
private function currentQueueSearchState(): string
{
$search = trim((string) ($this->tableSearch ?? ''));
if ($search !== '') {
return $search;
}
$persisted = session()->get($this->getTableSearchSessionKey(), '');
return trim(is_string($persisted) ? $persisted : '');
}
private function matchesSelectedFindingExceptionFilters(FindingException $record): bool
{
$filters = $this->currentQueueFiltersState();
$tenantFilter = data_get($filters, 'tenant_id.value');
if (is_numeric($tenantFilter) && (int) $record->tenant_id !== (int) $tenantFilter) {
return false;
}
$statusFilter = data_get($filters, 'status.value');
if (is_string($statusFilter) && $statusFilter !== '' && (string) $record->status !== $statusFilter) {
return false;
}
$validityFilter = data_get($filters, 'current_validity_state.value');
if (is_string($validityFilter) && $validityFilter !== '' && (string) $record->current_validity_state !== $validityFilter) {
return false;
}
return true;
}
private function matchesSelectedFindingExceptionSearch(FindingException $record): bool
{
$search = Str::lower($this->currentQueueSearchState());
if ($search === '') {
return true;
}
$haystack = Str::lower(implode(' ', [
$record->tenant?->name ?? '',
$record->finding?->resolvedSubjectDisplayName() ?? 'Finding #'.$record->finding_id,
$record->request_reason ?? '',
]));
return str_contains($haystack, $search);
}
private function governanceWarning(FindingException $record): ?string
{
$finding = $record->relationLoaded('finding')
? $record->finding
: $record->finding()->withSubjectDisplayName()->first();
if (! $finding instanceof \App\Models\Finding) {
return null;
}
return app(FindingRiskGovernanceResolver::class)->resolveWarningMessage($finding, $record);
}
private function governanceWarningColor(FindingException $record): string
{
if ((string) $record->current_validity_state === FindingException::VALIDITY_EXPIRING) {
return 'warning';
}
$finding = $record->relationLoaded('finding')
? $record->finding
: $record->finding()->withSubjectDisplayName()->first();
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
return 'warning';
}
return 'danger';
}
}