TenantAtlas/app/Filament/Resources/ReviewPackResource.php
ahmido 9f5c99317b Fix Review Pack generation UX + notifications (#133)
## Summary
- Fixes misleading “queued / running in background” message when Review Pack generation request reuses an existing ready pack (fingerprint dedupe).
- Improves resilience of Filament/Livewire interactions by ensuring the Livewire intercept shim applies after Livewire initializes.
- Aligns Review Pack operation notifications with Ops-UX patterns (queued + completed notifications) and removes the old ReviewPackStatusNotification.

## Key Changes
- Review Pack generate action now:
  - Shows queued toast only when a new pack is actually created/queued.
  - Shows a “Review pack already available” success notification with a link when dedupe returns an existing pack.

## Tests
- `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackGenerationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackResourceTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/LivewireInterceptShimTest.php`

## Notes
- No global search behavior changes for ReviewPacks (still excluded).
- Destructive actions remain confirmation-gated (`->requiresConfirmation()`).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #133
2026-02-23 19:42:52 +00:00

353 lines
15 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ReviewPackResource\Pages;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\User;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
use App\Support\ReviewPackStatus;
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 BackedEnum;
use Filament\Actions;
use Filament\Facades\Filament;
use Filament\Forms\Components\Toggle;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Number;
use UnitEnum;
class ReviewPackResource extends Resource
{
protected static ?string $model = ReviewPack::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-arrow-down';
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
protected static ?string $navigationLabel = 'Review Packs';
protected static ?int $navigationSort = 50;
public static function canViewAny(): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
return $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
}
public static function canView(Model $record): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
return false;
}
if ($record instanceof ReviewPack) {
return (int) $record->tenant_id === (int) $tenant->getKey();
}
return true;
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two primary row actions (Download, Expire); no secondary menu needed.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Status')
->schema([
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ReviewPackStatus))
->color(BadgeRenderer::color(BadgeDomain::ReviewPackStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus)),
TextEntry::make('tenant.name')->label('Tenant'),
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
TextEntry::make('file_size')
->label('File size')
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—'),
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
Section::make('Summary')
->schema([
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
TextEntry::make('summary.data_freshness.permission_posture')
->label('Permission posture freshness')
->placeholder('—'),
TextEntry::make('summary.data_freshness.entra_admin_roles')
->label('Entra admin roles freshness')
->placeholder('—'),
TextEntry::make('summary.data_freshness.findings')
->label('Findings freshness')
->placeholder('—'),
TextEntry::make('summary.data_freshness.hardening')
->label('Hardening freshness')
->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
Section::make('Options')
->schema([
TextEntry::make('options.include_pii')
->label('Include PII')
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
TextEntry::make('options.include_operations')
->label('Include operations')
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
])
->columns(2)
->columnSpanFull(),
Section::make('Metadata')
->schema([
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
TextEntry::make('operationRun.id')
->label('Operation run')
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
? route('admin.operations.view', ['run' => (int) $record->operation_run_id])
: null)
->openUrlInNewTab()
->placeholder('—'),
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—'),
TextEntry::make('created_at')->label('Created')->dateTime(),
])
->columns(2)
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->recordUrl(fn (ReviewPack $record): string => static::getUrl('view', ['record' => $record]))
->columns([
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ReviewPackStatus))
->color(BadgeRenderer::color(BadgeDomain::ReviewPackStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
->sortable(),
Tables\Columns\TextColumn::make('tenant.name')
->label('Tenant')
->searchable(),
Tables\Columns\TextColumn::make('generated_at')
->dateTime()
->sortable()
->placeholder('—'),
Tables\Columns\TextColumn::make('expires_at')
->dateTime()
->sortable()
->placeholder('—'),
Tables\Columns\TextColumn::make('file_size')
->label('Size')
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->label('Created')
->since()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options(collect(ReviewPackStatus::cases())
->mapWithKeys(fn (ReviewPackStatus $s): array => [$s->value => ucfirst($s->value)])
->all()),
])
->actions([
Actions\Action::make('download')
->label('Download')
->icon('heroicon-o-arrow-down-tray')
->color('success')
->visible(fn (ReviewPack $record): bool => $record->status === ReviewPackStatus::Ready->value)
->url(function (ReviewPack $record): string {
return app(ReviewPackService::class)->generateDownloadUrl($record);
})
->openUrlInNewTab(),
UiEnforcement::forAction(
Actions\Action::make('expire')
->label('Expire')
->icon('heroicon-o-clock')
->color('danger')
->visible(fn (ReviewPack $record): bool => $record->status === ReviewPackStatus::Ready->value)
->requiresConfirmation()
->modalDescription('This will mark the pack as expired and delete the file. This cannot be undone.')
->action(function (ReviewPack $record): void {
if ($record->file_path && $record->file_disk) {
\Illuminate\Support\Facades\Storage::disk($record->file_disk)->delete($record->file_path);
}
$record->update(['status' => ReviewPackStatus::Expired->value]);
Notification::make()
->success()
->title('Review pack expired')
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
])
->emptyStateHeading('No review packs yet')
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
->emptyStateIcon('heroicon-o-document-arrow-down')
->emptyStateActions([
UiEnforcement::forAction(
Actions\Action::make('generate_first')
->label('Generate first pack')
->icon('heroicon-o-plus')
->action(function (array $data): void {
static::executeGeneration($data);
})
->form([
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default(config('tenantpilot.review_pack.include_pii_default', true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default(config('tenantpilot.review_pack.include_operations_default', true)),
]),
])
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
]);
}
public static function getEloquentQuery(): Builder
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
}
public static function getPages(): array
{
return [
'index' => Pages\ListReviewPacks::route('/'),
'view' => Pages\ViewReviewPack::route('/{record}'),
];
}
/**
* @param array<string, mixed> $data
*/
public static function executeGeneration(array $data): void
{
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
Notification::make()->danger()->title('Unable to generate pack — missing context.')->send();
return;
}
$service = app(ReviewPackService::class);
if ($service->checkActiveRun($tenant)) {
Notification::make()->warning()->title('A review pack is already being generated.')->send();
return;
}
$options = [
'include_pii' => (bool) ($data['include_pii'] ?? true),
'include_operations' => (bool) ($data['include_operations'] ?? true),
];
$reviewPack = $service->generate($tenant, $user, $options);
if (! $reviewPack->wasRecentlyCreated) {
Notification::make()
->success()
->title('Review pack already available')
->body('A matching review pack is already ready. No new run was started.')
->actions([
Actions\Action::make('view_pack')
->label('View pack')
->url(static::getUrl('view', ['record' => $reviewPack], tenant: $tenant)),
])
->send();
return;
}
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
}
}