Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 5m7s
Added jobs, controllers, and PDF generation logic for management report runtime as defined in Spec 379. Includes artifact migrations, payload builders, and testing coverage.
722 lines
32 KiB
PHP
722 lines
32 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\Actions\ResolvesUiActionContext;
|
|
use App\Support\Rbac\Actions\UiActionContext;
|
|
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 ResolvesUiActionContext;
|
|
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, Management PDF generation/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('Output guidance')
|
|
->schema([
|
|
ViewEntry::make('output_guidance')
|
|
->hiddenLabel()
|
|
->view('filament.infolists.entries.review-pack-output-guidance')
|
|
->state(fn (ReviewPack $record): array => static::outputGuidanceState($record))
|
|
->columnSpanFull(),
|
|
])
|
|
->columnSpanFull(),
|
|
Section::make('Pack readiness and contents')
|
|
->schema([
|
|
TextEntry::make('status')
|
|
->label('Pack readiness')
|
|
->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('Environment'),
|
|
TextEntry::make('generated_at')
|
|
->label('Generated')
|
|
->dateTime()
|
|
->placeholder('—'),
|
|
TextEntry::make('expires_at')
|
|
->label('Expires')
|
|
->dateTime()
|
|
->placeholder('—'),
|
|
TextEntry::make('file_size')
|
|
->label('Download size')
|
|
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—'),
|
|
TextEntry::make('summary.finding_count')
|
|
->label('Findings included')
|
|
->placeholder('—'),
|
|
TextEntry::make('summary.report_count')
|
|
->label('Reports included')
|
|
->placeholder('—'),
|
|
TextEntry::make('summary.evidence_resolution.outcome')
|
|
->label('Evidence resolution')
|
|
->placeholder('—'),
|
|
TextEntry::make('evidenceSnapshot.completeness_state')
|
|
->label('Evidence basis')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
|
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
|
|
->placeholder('—')
|
|
->url(fn (ReviewPack $record): ?string => static::evidenceSnapshotUrl($record)),
|
|
TextEntry::make('environmentReview.status')
|
|
->label('Released review')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EnvironmentReviewStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::EnvironmentReviewStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::EnvironmentReviewStatus))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EnvironmentReviewStatus))
|
|
->placeholder('—')
|
|
->url(fn (ReviewPack $record): ?string => $record->environmentReview && $record->tenant
|
|
? EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $record->environmentReview], $record->tenant)
|
|
: null),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
|
|
Section::make('Technical pack details')
|
|
->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'),
|
|
TextEntry::make('initiator.name')->label('Initiated by')->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('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('—'),
|
|
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—')
|
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
|
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)
|
|
->collapsible()
|
|
->collapsed()
|
|
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
|
->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::forScopedAction(
|
|
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()),
|
|
fn (): UiActionContext => static::tenantUiActionContext(),
|
|
)
|
|
->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;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function outputGuidanceState(ReviewPack $record): array
|
|
{
|
|
$review = $record->environmentReview;
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return [
|
|
'label' => 'Review linkage unavailable',
|
|
'color' => 'warning',
|
|
'boundary_label' => 'Needs review',
|
|
'boundary_color' => 'warning',
|
|
'status_label' => 'Review linkage unavailable',
|
|
'primary_reason' => 'No released review is linked to this pack.',
|
|
'impact' => 'Treat this pack as technical evidence until the released review relationship is restored.',
|
|
'next_step_label' => 'Review the pack metadata before sharing.',
|
|
'detail_mode' => true,
|
|
'suppress_primary_action_button' => true,
|
|
'limitations' => [
|
|
[
|
|
'label' => 'Released review missing',
|
|
'severity' => 'warning',
|
|
'reason' => 'The pack cannot be framed as a customer review output without a linked released review.',
|
|
'details' => [],
|
|
],
|
|
],
|
|
'limitation_summary' => '1 limitation requires review',
|
|
'technical_details' => [],
|
|
];
|
|
}
|
|
|
|
$tenant = $record->tenant;
|
|
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness(
|
|
ReviewPackOutputResolutionGuidance::readinessForReview($review),
|
|
[
|
|
'download' => null,
|
|
'review' => $tenant instanceof ManagedEnvironment
|
|
? EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review], $tenant)
|
|
: null,
|
|
'evidence' => static::evidenceSnapshotUrl($record),
|
|
'operation' => null,
|
|
],
|
|
);
|
|
|
|
return array_replace($guidance, [
|
|
'detail_mode' => true,
|
|
'suppress_primary_action_button' => true,
|
|
'context_note' => 'Rendered-report and download actions stay in the page header when this pack is ready.',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
}
|
|
}
|