TenantAtlas/app/Filament/Pages/Monitoring/EvidenceOverview.php
ahmido 44898a98ac feat: harden evidence freshness publication trust (#205)
## Summary
- harden governance artifact truth propagation so stale or partial evidence downgrades evidence snapshots, tenant reviews, review packs, the canonical evidence overview, and the canonical review register consistently
- add the full Spec 174 artifact set under `specs/174-evidence-freshness-publication-trust/` including spec, plan, research, data model, contracts, quickstart, checklist, and completed tasks
- add focused fixture helpers plus a new browser smoke test for the touched evidence, review, and review-pack trust surfaces

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/Evidence/EvidenceOverviewPageTest.php tests/Feature/TenantReview/TenantReviewLifecycleTest.php tests/Feature/TenantReview/TenantReviewRegisterTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php tests/Browser/Spec174EvidenceFreshnessPublicationTrustSmokeTest.php`
- manual integrated-browser smoke pass across Evidence Overview, Review Register, tenant review detail, tenant evidence snapshot detail, and review-packs list

## Notes
- Livewire v4 compliance is preserved and no Filament v3/v4 APIs were introduced
- no panel or provider changes were made; Laravel 11+ provider registration remains in `bootstrap/providers.php`
- no new global-search behavior was introduced; existing resource view pages remain the relevant detail endpoints
- destructive actions were not broadened; existing confirmation and authorization behavior remains in place
- no new assets were added, so the current Filament asset strategy and deploy-time `php artisan filament:assets` behavior stay unchanged
- branch `174-evidence-freshness-publication-trust` is pushed at `7f2c82c26dc83bbc09fbf9e732d5644cdd143113` and targets `dev`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #205
2026-04-04 11:31:27 +00:00

166 lines
6.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Pages\Monitoring;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Models\EvidenceSnapshot;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\User;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\TenantReviewStatus;
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\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Illuminate\Auth\AuthenticationException;
use UnitEnum;
class EvidenceOverview extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $title = 'Evidence Overview';
protected string $view = 'filament.pages.monitoring.evidence-overview';
/**
* @var list<array<string, mixed>>
*/
public array $rows = [];
public ?int $tenantFilter = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'The overview header exposes a clear-filters action when a tenant prefilter is active.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The overview exposes a single drill-down link per row without a More menu.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The overview does not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains the current scope and offers a clear-filters CTA.');
}
public function mount(): void
{
$user = auth()->user();
if (! $user instanceof User) {
throw new AuthenticationException;
}
$workspaceContext = app(WorkspaceContext::class);
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
$workspaceId = (int) $workspace->getKey();
$accessibleTenants = $user->tenants()
->where('tenants.workspace_id', $workspaceId)
->orderBy('tenants.name')
->get()
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
->values();
$this->tenantFilter = is_numeric(request()->query('tenant_id')) ? (int) request()->query('tenant_id') : null;
$tenantIds = $accessibleTenants->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
$query = EvidenceSnapshot::query()
->with('tenant')
->where('workspace_id', $workspaceId)
->whereIn('tenant_id', $tenantIds)
->where('status', 'active')
->latest('generated_at');
if ($this->tenantFilter !== null) {
$query->where('tenant_id', $this->tenantFilter);
}
$snapshots = $query->get()->unique('tenant_id')->values();
$currentReviewTenantIds = TenantReview::query()
->where('workspace_id', $workspaceId)
->whereIn('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all())
->whereIn('status', [
TenantReviewStatus::Draft->value,
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
])
->pluck('tenant_id')
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
->all();
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot) use ($currentReviewTenantIds): array {
$truth = $this->snapshotTruth($snapshot);
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
$tenantId = (int) $snapshot->tenant_id;
$hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false;
$nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current'
? 'Create a current review from this evidence snapshot'
: $truth->nextStepText();
return [
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
'tenant_id' => $tenantId,
'snapshot_id' => (int) $snapshot->getKey(),
'completeness_state' => (string) $snapshot->completeness_state,
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
'artifact_truth' => [
'label' => $truth->primaryLabel,
'color' => $truth->primaryBadgeSpec()->color,
'icon' => $truth->primaryBadgeSpec()->icon,
'explanation' => $truth->primaryExplanation,
],
'freshness' => [
'label' => $freshnessSpec->label,
'color' => $freshnessSpec->color,
'icon' => $freshnessSpec->icon,
],
'next_step' => $nextStep,
'view_url' => $snapshot->tenant
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
: null,
];
})->all();
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('clear_filters')
->label('Clear filters')
->color('gray')
->visible(fn (): bool => $this->tenantFilter !== null)
->url(route('admin.evidence.overview')),
];
}
private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
{
$presenter = app(ArtifactTruthPresenter::class);
return $fresh
? $presenter->forEvidenceSnapshotFresh($snapshot)
: $presenter->forEvidenceSnapshot($snapshot);
}
}