TenantAtlas/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php
Ahmed Darrazi 8cc73dff71
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 4m59s
feat: add findings operator inbox
2026-04-21 10:19:14 +02:00

688 lines
22 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Pages\Findings;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\CanonicalAdminTenantFilterState;
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\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\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class MyFindingsInbox extends Page implements HasTable
{
use InteractsWithTable;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $title = 'My Findings';
protected static ?string $slug = 'findings/my-work';
protected string $view = 'filament.pages.findings.my-findings-inbox';
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $visibleTenants = null;
private ?Workspace $workspace = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the assigned-to-me scope fixed and expose only a tenant-prefilter clear action when needed.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The personal findings inbox does not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state stays calm, explains scope boundaries, and offers exactly one recovery CTA.')
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the existing tenant finding detail page.');
}
public function mount(): void
{
$this->authorizePageAccess();
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
['overdue', 'reopened', 'high_severity'],
request(),
);
$this->applyRequestedTenantPrefilter();
$this->mountInteractsWithTable();
$this->normalizeTenantFilterState();
}
protected function getHeaderActions(): array
{
return [
Action::make('clear_tenant_filter')
->label('Clear tenant filter')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter()),
];
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->queueBaseQuery())
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->columns([
TextColumn::make('tenant.name')
->label('Tenant'),
TextColumn::make('subject_display_name')
->label('Finding')
->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey())
->description(fn (Finding $record): ?string => $this->ownerContext($record))
->wrap(),
TextColumn::make('severity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus))
->description(fn (Finding $record): ?string => $this->reopenedCue($record)),
TextColumn::make('due_at')
->label('Due')
->dateTime()
->placeholder('—')
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? FindingResource::dueAttentionLabelFor($record)),
])
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->tenantFilterOptions())
->searchable(),
Filter::make('overdue')
->label('Overdue')
->query(fn (Builder $query): Builder => $query
->whereNotNull('due_at')
->where('due_at', '<', now())),
Filter::make('reopened')
->label('Reopened')
->query(fn (Builder $query): Builder => $query->whereNotNull('reopened_at')),
Filter::make('high_severity')
->label('High severity')
->query(fn (Builder $query): Builder => $query->whereIn('severity', Finding::highSeverityValues())),
])
->actions([])
->bulkActions([])
->recordUrl(fn (Finding $record): string => $this->findingDetailUrl($record))
->emptyStateHeading(fn (): string => $this->emptyState()['title'])
->emptyStateDescription(fn (): string => $this->emptyState()['body'])
->emptyStateIcon(fn (): string => $this->emptyState()['icon'])
->emptyStateActions($this->emptyStateActions());
}
/**
* @return array<string, mixed>
*/
public function appliedScope(): array
{
$tenant = $this->filteredTenant();
return [
'workspace_scoped' => true,
'assignee_scope' => 'current_user_only',
'tenant_prefilter_source' => $this->tenantPrefilterSource(),
'tenant_label' => $tenant?->name,
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function availableFilters(): array
{
return [
[
'key' => 'assignee_scope',
'label' => 'Assigned to me',
'fixed' => true,
'options' => [],
],
[
'key' => 'tenant',
'label' => 'Tenant',
'fixed' => false,
'options' => collect($this->visibleTenants())
->map(fn (Tenant $tenant): array => [
'value' => (string) $tenant->getKey(),
'label' => (string) $tenant->name,
])
->values()
->all(),
],
[
'key' => 'overdue',
'label' => 'Overdue',
'fixed' => false,
'options' => [],
],
[
'key' => 'reopened',
'label' => 'Reopened',
'fixed' => false,
'options' => [],
],
[
'key' => 'high_severity',
'label' => 'High severity',
'fixed' => false,
'options' => [],
],
];
}
/**
* @return array{open_assigned: int, overdue_assigned: int}
*/
public function summaryCounts(): array
{
$query = $this->filteredQueueQuery();
return [
'open_assigned' => (clone $query)->count(),
'overdue_assigned' => (clone $query)
->whereNotNull('due_at')
->where('due_at', '<', now())
->count(),
];
}
/**
* @return array<string, mixed>
*/
public function emptyState(): array
{
if ($this->tenantFilterAloneExcludesRows()) {
return [
'title' => 'No assigned findings match this tenant scope',
'body' => 'Your current tenant filter is hiding assigned work that is still visible elsewhere in this workspace.',
'icon' => 'heroicon-o-funnel',
'action_name' => 'clear_tenant_filter_empty',
'action_label' => 'Clear tenant filter',
'action_kind' => 'clear_tenant_filter',
];
}
$activeTenant = $this->activeVisibleTenant();
if ($activeTenant instanceof Tenant) {
return [
'title' => 'No visible assigned findings right now',
'body' => 'Nothing currently assigned to you needs attention in the visible tenant scope. You can still open tenant findings for broader context.',
'icon' => 'heroicon-o-clipboard-document-check',
'action_name' => 'open_tenant_findings_empty',
'action_label' => 'Open tenant findings',
'action_kind' => 'url',
'action_url' => FindingResource::getUrl('index', panel: 'tenant', tenant: $activeTenant),
];
}
return [
'title' => 'No visible assigned findings right now',
'body' => 'Nothing currently assigned to you needs attention across the visible tenant scope. Choose a tenant to continue working elsewhere in the workspace.',
'icon' => 'heroicon-o-clipboard-document-check',
'action_name' => 'choose_tenant_empty',
'action_label' => 'Choose a tenant',
'action_kind' => 'url',
'action_url' => route('filament.admin.pages.choose-tenant'),
];
}
public function updatedTableFilters(): void
{
$this->normalizeTenantFilterState();
}
public function clearTenantFilter(): void
{
$this->removeTableFilter('tenant_id');
$this->resetTable();
}
/**
* @return array<int, Tenant>
*/
public function visibleTenants(): array
{
if ($this->visibleTenants !== null) {
return $this->visibleTenants;
}
$user = auth()->user();
$tenants = $this->authorizedTenants();
if (! $user instanceof User || $tenants === []) {
return $this->visibleTenants = [];
}
$resolver = app(CapabilityResolver::class);
$resolver->primeMemberships(
$user,
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
);
return $this->visibleTenants = array_values(array_filter(
$tenants,
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
));
}
private function authorizePageAccess(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
throw new NotFoundHttpException;
}
}
/**
* @return array<int, Tenant>
*/
private function authorizedTenants(): array
{
if ($this->authorizedTenants !== null) {
return $this->authorizedTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->authorizedTenants = [];
}
return $this->authorizedTenants = $user->tenants()
->where('tenants.workspace_id', (int) $workspace->getKey())
->where('tenants.status', 'active')
->orderBy('tenants.name')
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
->all();
}
private function workspace(): ?Workspace
{
if ($this->workspace instanceof Workspace) {
return $this->workspace;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return null;
}
return $this->workspace = Workspace::query()->whereKey($workspaceId)->first();
}
private function queueBaseQuery(): Builder
{
$user = auth()->user();
$workspace = $this->workspace();
$tenantIds = array_map(
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
$this->visibleTenants(),
);
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return Finding::query()->whereRaw('1 = 0');
}
return Finding::query()
->with(['tenant', 'ownerUser', 'assigneeUser'])
->withSubjectDisplayName()
->where('workspace_id', (int) $workspace->getKey())
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->where('assignee_user_id', (int) $user->getKey())
->whereIn('status', Finding::openStatusesForQuery())
->orderByRaw(
'case when due_at is not null and due_at < ? then 0 when reopened_at is not null then 1 else 2 end asc',
[now()],
)
->orderByRaw('case when due_at is null then 1 else 0 end asc')
->orderBy('due_at')
->orderByDesc('id');
}
private function filteredQueueQuery(bool $includeTenantFilter = true): Builder
{
$query = $this->queueBaseQuery();
$filters = $this->currentQueueFiltersState();
if ($includeTenantFilter && ($tenantId = $this->currentTenantFilterIdFromFilters($filters)) !== null) {
$query->where('tenant_id', $tenantId);
}
if ($this->filterIsActive($filters, 'overdue')) {
$query
->whereNotNull('due_at')
->where('due_at', '<', now());
}
if ($this->filterIsActive($filters, 'reopened')) {
$query->whereNotNull('reopened_at');
}
if ($this->filterIsActive($filters, 'high_severity')) {
$query->whereIn('severity', Finding::highSeverityValues());
}
return $query;
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return collect($this->visibleTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => (string) $tenant->name,
])
->all();
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant');
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
return;
}
foreach ($this->visibleTenants() 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 normalizeTenantFilterState(): void
{
$configuredTenantFilter = data_get($this->currentQueueFiltersState(), 'tenant_id.value');
if ($configuredTenantFilter === null || $configuredTenantFilter === '') {
return;
}
if ($this->currentTenantFilterId() !== null) {
return;
}
$this->removeTableFilter('tenant_id');
}
/**
* @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 currentTenantFilterId(): ?int
{
return $this->currentTenantFilterIdFromFilters($this->currentQueueFiltersState());
}
/**
* @param array<string, mixed> $filters
*/
private function currentTenantFilterIdFromFilters(array $filters): ?int
{
$tenantFilter = data_get($filters, 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
return null;
}
$tenantId = (int) $tenantFilter;
foreach ($this->visibleTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenantId;
}
}
return null;
}
/**
* @param array<string, mixed> $filters
*/
private function filterIsActive(array $filters, string $name): bool
{
return (bool) data_get($filters, "{$name}.isActive", false);
}
private function filteredTenant(): ?Tenant
{
$tenantId = $this->currentTenantFilterId();
if (! is_int($tenantId)) {
return null;
}
foreach ($this->visibleTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenant;
}
}
return null;
}
private function activeVisibleTenant(): ?Tenant
{
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if (! $activeTenant instanceof Tenant) {
return null;
}
foreach ($this->visibleTenants() as $tenant) {
if ($tenant->is($activeTenant)) {
return $tenant;
}
}
return null;
}
private function tenantPrefilterSource(): string
{
$tenant = $this->filteredTenant();
if (! $tenant instanceof Tenant) {
return 'none';
}
$activeTenant = $this->activeVisibleTenant();
if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) {
return 'active_tenant_context';
}
return 'explicit_filter';
}
private function ownerContext(Finding $record): ?string
{
$ownerLabel = FindingResource::accountableOwnerDisplayFor($record);
$assigneeLabel = $record->assigneeUser?->name;
if ($record->owner_user_id === null || $ownerLabel === $assigneeLabel) {
return null;
}
return 'Owner: '.$ownerLabel;
}
private function reopenedCue(Finding $record): ?string
{
if ($record->reopened_at === null) {
return null;
}
return 'Reopened';
}
private function tenantFilterAloneExcludesRows(): bool
{
if ($this->currentTenantFilterId() === null) {
return false;
}
if ((clone $this->filteredQueueQuery())->exists()) {
return false;
}
return (clone $this->filteredQueueQuery(includeTenantFilter: false))->exists();
}
private function findingDetailUrl(Finding $record): string
{
$tenant = $record->tenant;
if (! $tenant instanceof Tenant) {
return '#';
}
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
return $this->appendQuery($url, $this->navigationContext()->toQuery());
}
private function navigationContext(): CanonicalNavigationContext
{
return new CanonicalNavigationContext(
sourceSurface: 'findings.my_inbox',
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->currentTenantFilterId(),
backLinkLabel: 'Back to my findings',
backLinkUrl: $this->queueUrl(),
);
}
private function queueUrl(): string
{
$tenant = $this->filteredTenant();
return static::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $tenant?->external_id,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
/**
* @return array<int, Action>
*/
private function emptyStateActions(): array
{
$emptyState = $this->emptyState();
$action = Action::make((string) $emptyState['action_name'])
->label((string) $emptyState['action_label'])
->icon('heroicon-o-arrow-right')
->color('gray');
if (($emptyState['action_kind'] ?? null) === 'clear_tenant_filter') {
return [
$action->action(fn (): mixed => $this->clearTenantFilter()),
];
}
return [
$action->url((string) $emptyState['action_url']),
];
}
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
}
}