Implemented the first version of the PDF and HTML renderer for review packs. Added ReviewPackRenderedReportController and related blade views to render reports. Updated EnvironmentReviewResource, ReviewPackResource, ReviewPackService, and routing. Added new tests for the renderer and download actions, and updated UI documentation. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #427
665 lines
29 KiB
PHP
665 lines
29 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
|
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
|
use App\Filament\Concerns\WorkspaceScopedEnvironmentRoutes;
|
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
|
|
use App\Filament\Resources\ReviewPackResource\Pages;
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\User;
|
|
use App\Services\ReviewPackService;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\Navigation\NavigationScope;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
|
|
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 App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
|
|
use App\Support\Ui\GovernanceArtifactTruth\SurfaceCompressionContext;
|
|
use BackedEnum;
|
|
use Filament\Actions;
|
|
use Filament\Forms\Components\Toggle;
|
|
use Filament\Infolists\Components\TextEntry;
|
|
use Filament\Infolists\Components\ViewEntry;
|
|
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
|
|
{
|
|
use ResolvesPanelTenantContext;
|
|
use WorkspaceScopedEnvironmentRoutes;
|
|
|
|
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 shouldRegisterNavigation(): bool
|
|
{
|
|
return NavigationScope::shouldRegisterEnvironmentNavigation()
|
|
&& parent::shouldRegisterNavigation();
|
|
}
|
|
|
|
public static function canViewAny(): bool
|
|
{
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! $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 = static::resolveTenantContextForCurrentPanel();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! $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->managed_environment_id === (int) $tenant->getKey();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action appears in the list header once review packs exist.')
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state carries the single Generate CTA while the registry is empty.')
|
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download remains the only direct row shortcut and Expire is grouped under More.')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Rendered-report preview stays primary while Download and Regenerate remain available in the ViewReviewPack header.');
|
|
}
|
|
|
|
public static function form(Schema $schema): Schema
|
|
{
|
|
return $schema;
|
|
}
|
|
|
|
public static function infolist(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->schema([
|
|
Section::make('Outcome summary')
|
|
->schema([
|
|
ViewEntry::make('artifact_truth')
|
|
->hiddenLabel()
|
|
->view('filament.infolists.entries.governance-artifact-truth')
|
|
->state(fn (ReviewPack $record): array => static::truthState($record))
|
|
->columnSpanFull(),
|
|
])
|
|
->columnSpanFull(),
|
|
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('ManagedEnvironment'),
|
|
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('—')
|
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
|
])
|
|
->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)
|
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
|
->columnSpanFull(),
|
|
|
|
Section::make('Metadata')
|
|
->schema([
|
|
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
|
|
TextEntry::make('environmentReview.id')
|
|
->label('ManagedEnvironment review')
|
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
->url(fn (ReviewPack $record): ?string => $record->environmentReview && $record->tenant
|
|
? EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $record->environmentReview], $record->tenant)
|
|
: null)
|
|
->placeholder('—'),
|
|
TextEntry::make('customer_workspace')
|
|
->label('Customer workspace')
|
|
->state(fn (): string => 'Open workspace')
|
|
->url(fn (ReviewPack $record): ?string => $record->tenant instanceof ManagedEnvironment
|
|
? CustomerReviewWorkspace::environmentFilterUrl($record->tenant)
|
|
: null)
|
|
->placeholder('—'),
|
|
TextEntry::make('summary.review_status')
|
|
->label('Review status')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EnvironmentReviewStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::EnvironmentReviewStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::EnvironmentReviewStatus))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EnvironmentReviewStatus))
|
|
->placeholder('—'),
|
|
TextEntry::make('operationRun.id')
|
|
->label('Operation')
|
|
->url(function (ReviewPack $record): ?string {
|
|
if (! $record->operation_run_id) {
|
|
return null;
|
|
}
|
|
|
|
$tenant = $record->tenant;
|
|
|
|
if ($tenant instanceof ManagedEnvironment) {
|
|
return OperationRunLinks::view((int) $record->operation_run_id, $tenant);
|
|
}
|
|
|
|
return OperationRunLinks::tenantlessView((int) $record->operation_run_id);
|
|
})
|
|
->openUrlInNewTab()
|
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
|
->placeholder('—'),
|
|
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—')
|
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
|
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—')
|
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
|
TextEntry::make('created_at')->label('Created')->dateTime(),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
|
|
Section::make('Evidence snapshot')
|
|
->schema([
|
|
TextEntry::make('summary.evidence_resolution.outcome')
|
|
->label('Resolution')
|
|
->placeholder('—'),
|
|
TextEntry::make('evidenceSnapshot.id')
|
|
->label('Snapshot')
|
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
->url(fn (ReviewPack $record): ?string => static::evidenceSnapshotUrl($record)),
|
|
TextEntry::make('evidenceSnapshot.completeness_state')
|
|
->label('Snapshot completeness')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
|
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
|
|
->placeholder('—'),
|
|
TextEntry::make('summary.evidence_resolution.snapshot_fingerprint')
|
|
->label('Snapshot fingerprint')
|
|
->copyable()
|
|
->placeholder('—'),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
]);
|
|
}
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->defaultSort('created_at', 'desc')
|
|
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
|
->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('outcome')
|
|
->label('Outcome')
|
|
->badge()
|
|
->getStateUsing(fn (ReviewPack $record): string => static::compressedOutcome($record)->primaryLabel)
|
|
->color(fn (ReviewPack $record): string => static::compressedOutcome($record)->primaryBadge->color)
|
|
->icon(fn (ReviewPack $record): ?string => static::compressedOutcome($record)->primaryBadge->icon)
|
|
->iconColor(fn (ReviewPack $record): ?string => static::compressedOutcome($record)->primaryBadge->iconColor)
|
|
->description(fn (ReviewPack $record): ?string => static::compressedOutcome($record)->primaryReason)
|
|
->wrap(),
|
|
Tables\Columns\TextColumn::make('tenant.name')
|
|
->label('ManagedEnvironment')
|
|
->searchable(),
|
|
Tables\Columns\TextColumn::make('generated_at')
|
|
->dateTime()
|
|
->sortable()
|
|
->placeholder('—'),
|
|
Tables\Columns\TextColumn::make('environmentReview.id')
|
|
->label('Review')
|
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
->toggleable(isToggledHiddenByDefault: true),
|
|
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('next_step')
|
|
->label('Next step')
|
|
->getStateUsing(fn (ReviewPack $record): string => static::compressedOutcome($record)->nextActionText)
|
|
->wrap(),
|
|
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(fn (ReviewPack $record): string => static::downloadActionLabelFor($record))
|
|
->icon('heroicon-o-arrow-down-tray')
|
|
->color('gray')
|
|
->visible(fn (ReviewPack $record): bool => $record->status === ReviewPackStatus::Ready->value)
|
|
->url(function (ReviewPack $record): string {
|
|
return app(ReviewPackService::class)->generateDownloadUrl($record);
|
|
})
|
|
->openUrlInNewTab(),
|
|
Actions\ActionGroup::make([
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('expire')
|
|
->label('Expire')
|
|
->icon('heroicon-o-clock')
|
|
->color('danger')
|
|
->hidden(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]);
|
|
static::truthEnvelope($record->refresh(), fresh: true);
|
|
|
|
Notification::make()
|
|
->success()
|
|
->title('Review pack expired')
|
|
->send();
|
|
}),
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
|
->apply(),
|
|
])->label('More'),
|
|
])
|
|
->emptyStateHeading('No review packs yet')
|
|
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
|
|
->emptyStateIcon('heroicon-o-document-arrow-down')
|
|
->emptyStateActions([
|
|
static::generatePackAction(name: 'generate_first', label: 'Generate first pack'),
|
|
]);
|
|
}
|
|
|
|
public static function generatePackAction(string $name = 'generate_pack', string $label = 'Generate Pack'): Actions\Action
|
|
{
|
|
$action = UiEnforcement::forAction(
|
|
Actions\Action::make($name)
|
|
->label($label)
|
|
->icon('heroicon-o-plus')
|
|
->disabled(fn (): bool => static::reviewPackGenerationBlocked())
|
|
->action(function (array $data): void {
|
|
static::executeGeneration($data);
|
|
})
|
|
->form(static::reviewPackGenerationFormSchema())
|
|
)
|
|
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
|
->preserveDisabled()
|
|
->apply();
|
|
|
|
$action->tooltip(fn (): ?string => static::reviewPackGenerationActionTooltip());
|
|
|
|
return $action;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, Section>
|
|
*/
|
|
public static function reviewPackGenerationFormSchema(): array
|
|
{
|
|
return [
|
|
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)),
|
|
]),
|
|
];
|
|
}
|
|
|
|
public static function getEloquentQuery(): Builder
|
|
{
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
|
}
|
|
|
|
return parent::getEloquentQuery()
|
|
->with(['tenant', 'operationRun', 'evidenceSnapshot', 'environmentReview'])
|
|
->where('managed_environment_id', (int) $tenant->getKey());
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => Pages\ListReviewPacks::route('/'),
|
|
'view' => Pages\ViewReviewPack::route('/{record}'),
|
|
];
|
|
}
|
|
|
|
public static function downloadActionLabelFor(ReviewPack $record): string
|
|
{
|
|
$review = $record->environmentReview;
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return __('localization.review.download_current_review_pack');
|
|
}
|
|
|
|
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness(
|
|
ReviewPackOutputResolutionGuidance::readinessForReview($review),
|
|
);
|
|
|
|
return match ((string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN)) {
|
|
ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => __('localization.review.download_customer_safe_review_pack'),
|
|
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY => __('localization.review.download_internal_review_pack'),
|
|
default => __('localization.review.download_review_pack_with_limitations'),
|
|
};
|
|
}
|
|
|
|
public static function isCustomerWorkspaceFlow(): bool
|
|
{
|
|
return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;
|
|
}
|
|
|
|
private static function evidenceSnapshotUrl(ReviewPack $record): ?string
|
|
{
|
|
if (! $record->evidenceSnapshot) {
|
|
return null;
|
|
}
|
|
|
|
$url = TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant);
|
|
|
|
return static::isCustomerWorkspaceFlow()
|
|
? static::appendQuery($url, ['source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE])
|
|
: $url;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $query
|
|
*/
|
|
private static function appendQuery(string $url, array $query): string
|
|
{
|
|
if ($query === []) {
|
|
return $url;
|
|
}
|
|
|
|
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
|
}
|
|
|
|
private static function truthEnvelope(ReviewPack $record, bool $fresh = false): ArtifactTruthEnvelope
|
|
{
|
|
$presenter = app(ArtifactTruthPresenter::class);
|
|
|
|
return $fresh
|
|
? $presenter->forReviewPackFresh($record)
|
|
: $presenter->forReviewPack($record);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function truthState(ReviewPack $record, bool $fresh = false): array
|
|
{
|
|
$presenter = app(ArtifactTruthPresenter::class);
|
|
$truth = $fresh
|
|
? static::truthEnvelope($record, true)
|
|
: static::truthEnvelope($record);
|
|
|
|
return $presenter->surfaceStateFor($record, SurfaceCompressionContext::reviewPack(), $fresh)
|
|
?? $truth->toArray(static::compressedOutcome($record, $fresh));
|
|
}
|
|
|
|
private static function compressedOutcome(ReviewPack $record, bool $fresh = false): CompressedGovernanceOutcome
|
|
{
|
|
$presenter = app(ArtifactTruthPresenter::class);
|
|
|
|
return $presenter->compressedOutcomeFor($record, SurfaceCompressionContext::reviewPack(), $fresh)
|
|
?? $presenter->compressedOutcomeFromEnvelope(
|
|
static::truthEnvelope($record, $fresh),
|
|
SurfaceCompressionContext::reviewPack(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public static function executeGeneration(array $data): void
|
|
{
|
|
$tenant = static::currentTenantContext();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! $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),
|
|
];
|
|
|
|
try {
|
|
$reviewPack = $service->generate($tenant, $user, $options);
|
|
} catch (WorkspaceEntitlementBlockedException $exception) {
|
|
Notification::make()
|
|
->warning()
|
|
->title('Review pack generation unavailable')
|
|
->body($exception->getMessage())
|
|
->send();
|
|
|
|
return;
|
|
} catch (ReviewPackEvidenceResolutionException $exception) {
|
|
$reasons = $exception->result->reasons;
|
|
|
|
Notification::make()
|
|
->danger()
|
|
->title(match ($exception->result->outcome) {
|
|
'missing_snapshot' => 'Create snapshot required',
|
|
'snapshot_ineligible' => 'Snapshot is not eligible',
|
|
default => 'Unable to generate review pack',
|
|
})
|
|
->body($reasons === [] ? $exception->getMessage() : implode(' ', $reasons))
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
static::truthEnvelope($reviewPack->refresh(), fresh: true);
|
|
|
|
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('environment.review_pack.generate')->send();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function reviewPackGenerationDecision(?ManagedEnvironment $tenant = null): array
|
|
{
|
|
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return [];
|
|
}
|
|
|
|
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
|
|
}
|
|
|
|
public static function currentTenantContext(): ?ManagedEnvironment
|
|
{
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
|
|
return $tenant instanceof ManagedEnvironment ? $tenant : null;
|
|
}
|
|
|
|
public static function reviewPackGenerationBlocked(?ManagedEnvironment $tenant = null): bool
|
|
{
|
|
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
|
|
}
|
|
|
|
public static function reviewPackGenerationBlockReason(?ManagedEnvironment $tenant = null): ?string
|
|
{
|
|
$decision = static::reviewPackGenerationDecision($tenant);
|
|
|
|
if (! (bool) ($decision['is_blocked'] ?? false)) {
|
|
return null;
|
|
}
|
|
|
|
$reason = $decision['block_reason'] ?? null;
|
|
|
|
return is_string($reason) && $reason !== '' ? $reason : null;
|
|
}
|
|
|
|
public static function reviewPackGenerationWarningReason(?ManagedEnvironment $tenant = null): ?string
|
|
{
|
|
$decision = static::reviewPackGenerationDecision($tenant);
|
|
|
|
if (! (bool) ($decision['is_warning'] ?? false)) {
|
|
return null;
|
|
}
|
|
|
|
$reason = $decision['warning_reason'] ?? null;
|
|
|
|
return is_string($reason) && $reason !== '' ? $reason : null;
|
|
}
|
|
|
|
public static function reviewPackGenerationActionTooltip(?ManagedEnvironment $tenant = null): ?string
|
|
{
|
|
$tenant ??= static::currentTenantContext();
|
|
$user = auth()->user();
|
|
|
|
if ($tenant instanceof ManagedEnvironment && $user instanceof User && ! $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant)) {
|
|
return AuthUiTooltips::insufficientPermission();
|
|
}
|
|
|
|
return static::reviewPackGenerationBlockReason($tenant)
|
|
?? static::reviewPackGenerationWarningReason($tenant);
|
|
}
|
|
}
|