'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|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 */ 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 */ 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 */ 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 */ 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'; } }