504 lines
19 KiB
PHP
504 lines
19 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\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\Filament\FilterOptionCatalog;
|
|
use App\Support\Filament\TablePaginationProfiles;
|
|
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\Workspaces\WorkspaceContext;
|
|
use BackedEnum;
|
|
use Filament\Actions\Action;
|
|
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 Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
use UnitEnum;
|
|
|
|
class FindingExceptionsQueue extends Page implements HasTable
|
|
{
|
|
use InteractsWithTable;
|
|
|
|
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)
|
|
->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.');
|
|
}
|
|
|
|
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->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
|
|
$this->mountInteractsWithTable();
|
|
$this->applyRequestedTenantPrefilter();
|
|
|
|
if ($this->selectedFindingExceptionId !== null) {
|
|
$this->selectedFindingException();
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
$actions[] = Action::make('clear_selected_exception')
|
|
->label('Close details')
|
|
->color('gray')
|
|
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
|
->action(function (): void {
|
|
$this->selectedFindingExceptionId = null;
|
|
});
|
|
|
|
$actions[] = Action::make('open_selected_exception')
|
|
->label('Open tenant detail')
|
|
->icon('heroicon-o-arrow-top-right-on-square')
|
|
->color('gray')
|
|
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
|
->url(fn (): ?string => $this->selectedExceptionUrl());
|
|
|
|
$actions[] = Action::make('open_selected_finding')
|
|
->label('Open finding')
|
|
->icon('heroicon-o-arrow-top-right-on-square')
|
|
->color('gray')
|
|
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
|
->url(fn (): ?string => $this->selectedFindingUrl());
|
|
|
|
$actions[] = Action::make('approve_selected_exception')
|
|
->label('Approve exception')
|
|
->color('success')
|
|
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
|
->requiresConfirmation()
|
|
->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)
|
|
->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' : 'Exception approved')
|
|
->success()
|
|
->send();
|
|
});
|
|
|
|
$actions[] = Action::make('reject_selected_exception')
|
|
->label('Reject exception')
|
|
->color('danger')
|
|
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
|
->requiresConfirmation()
|
|
->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' : 'Exception rejected')
|
|
->success()
|
|
->send();
|
|
});
|
|
|
|
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('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')
|
|
->action(function (FindingException $record): void {
|
|
$this->selectedFindingExceptionId = (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 selectedFindingException(): ?FindingException
|
|
{
|
|
if (! is_int($this->selectedFindingExceptionId)) {
|
|
return null;
|
|
}
|
|
|
|
$record = $this->queueBaseQuery()
|
|
->whereKey($this->selectedFindingExceptionId)
|
|
->first();
|
|
|
|
if (! $record instanceof FindingException) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
return $record;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* @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 = data_get($this->tableFilters, 'tenant_id.value');
|
|
|
|
if (! is_numeric($tenantFilter)) {
|
|
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
|
}
|
|
|
|
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'));
|
|
}
|
|
}
|