Implemented the first version of review output resolve actions. Included a ReviewOutputResolveActionMapper, commands to seed browser fixtures, updated CustomerReviewWorkspace, EnvironmentReviewResource, UI enforcement, and related views. Also added extensive unit, feature, and browser tests, and updated the design coverage matrix. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #422
3380 lines
141 KiB
PHP
3380 lines
141 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages\Reviews;
|
|
|
|
use App\Filament\Concerns\CleansAdminTenantQueryParameter;
|
|
use App\Filament\Concerns\ClearsWorkspaceHubEnvironmentFilterState;
|
|
use App\Filament\Resources\EnvironmentReviewResource;
|
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\EnvironmentReviewAcknowledgement;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\Finding;
|
|
use App\Models\FindingException;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\EnvironmentReviews\EnvironmentReviewAcknowledgementService;
|
|
use App\Services\EnvironmentReviews\EnvironmentReviewLifecycleService;
|
|
use App\Services\EnvironmentReviews\EnvironmentReviewRegisterService;
|
|
use App\Services\ReviewPackService;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\EnvironmentReviewCompletenessState;
|
|
use App\Support\EnvironmentReviewStatus;
|
|
use App\Support\Filament\TablePaginationProfiles;
|
|
use App\Support\Findings\FindingOutcomeSemantics;
|
|
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
|
use App\Support\Navigation\CanonicalNavigationContext;
|
|
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
|
|
use App\Support\Navigation\WorkspaceHubNavigation;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\ResolutionGuidance\Adapters\ReviewPackOutputResolutionAdapter;
|
|
use App\Support\ResolutionGuidance\ResolutionAction;
|
|
use App\Support\ResolutionGuidance\ResolutionCase;
|
|
use App\Support\ReviewPacks\ReviewPackOutputReadiness;
|
|
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
|
|
use App\Support\ReviewPackStatus;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
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 App\Support\Workspaces\WorkspaceContext;
|
|
use BackedEnum;
|
|
use Filament\Actions\Action;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Concerns\InteractsWithTable;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use Filament\Tables\Filters\SelectFilter;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
use UnitEnum;
|
|
|
|
class CustomerReviewWorkspace extends Page implements HasTable
|
|
{
|
|
use CleansAdminTenantQueryParameter;
|
|
use ClearsWorkspaceHubEnvironmentFilterState;
|
|
use InteractsWithTable;
|
|
|
|
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
|
|
|
|
public const string SOURCE_SURFACE = 'customer_review_workspace';
|
|
|
|
private const array ACCEPTED_RISK_FOLLOW_UP_STATES = [
|
|
'expiring_exception',
|
|
'expired_exception',
|
|
'revoked_exception',
|
|
'risk_accepted_without_valid_exception',
|
|
'pending_exception',
|
|
];
|
|
|
|
protected static bool $isDiscovered = false;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
|
|
|
protected static ?string $navigationLabel = 'Customer reviews';
|
|
|
|
protected static ?int $navigationSort = 44;
|
|
|
|
protected static ?string $title = 'Customer Review Workspace';
|
|
|
|
protected static ?string $slug = 'reviews/workspace';
|
|
|
|
protected string $view = 'filament.pages.reviews.customer-review-workspace';
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the customer review workspace.')
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::PrimaryLinkColumn->value)
|
|
->withPrimaryLinkColumnReason('Only the dedicated review-open column should navigate away; the rest of the row stays comparative workspace context.')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The customer review workspace remains scan-first and does not expose bulk actions.')
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA when filters are active.')
|
|
->exempt(ActionSurfaceSlot::DetailHeader, 'The dedicated open link column opens the latest published review detail instead of an inline canonical detail panel.');
|
|
}
|
|
|
|
public static function getNavigationGroup(): string
|
|
{
|
|
return WorkspaceHubNavigation::workspaceWideGroup(__('localization.review.reporting'));
|
|
}
|
|
|
|
public static function getNavigationLabel(): string
|
|
{
|
|
return __('localization.review.customer_reviews');
|
|
}
|
|
|
|
public static function getNavigationUrl(): string
|
|
{
|
|
return WorkspaceHubNavigation::environmentFilteredUrl(static::getUrl(panel: 'admin'));
|
|
}
|
|
|
|
public function getTitle(): string
|
|
{
|
|
return __('localization.review.customer_review_workspace');
|
|
}
|
|
|
|
public static function environmentFilterUrl(ManagedEnvironment $tenant): string
|
|
{
|
|
return static::getUrl(panel: 'admin').'?'.http_build_query([
|
|
'environment_id' => (int) $tenant->getKey(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @var array<int, ManagedEnvironment>|null
|
|
*/
|
|
private ?array $authorizedTenants = null;
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->authorizePageAccess();
|
|
$this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request());
|
|
$this->applyRequestedTenantPrefilter();
|
|
$this->mountInteractsWithTable();
|
|
$this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request());
|
|
$this->auditWorkspaceOpen();
|
|
}
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
$actions = [];
|
|
|
|
$governanceContext = $this->incomingGovernanceContext();
|
|
|
|
if ($governanceContext?->backLinkUrl !== null) {
|
|
$actions[] = Action::make('return_to_governance_inbox')
|
|
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
|
|
->icon('heroicon-o-arrow-left')
|
|
->color('gray')
|
|
->url($governanceContext->backLinkUrl);
|
|
}
|
|
|
|
$actions[] = Action::make('clear_filters')
|
|
->label(__('localization.review.clear_filters'))
|
|
->icon('heroicon-o-x-mark')
|
|
->color('gray')
|
|
->visible(fn (): bool => $this->hasActiveFilters())
|
|
->action(function (): void {
|
|
$this->clearWorkspaceFilters();
|
|
});
|
|
|
|
return $actions;
|
|
}
|
|
|
|
public function acknowledgeReviewAction(): Action
|
|
{
|
|
return Action::make('acknowledgeReview')
|
|
->label(__('localization.review.acknowledge_review'))
|
|
->icon('heroicon-o-check-badge')
|
|
->color('primary')
|
|
->record(fn (): ?ManagedEnvironment => $this->latestReleasedTenant())
|
|
->requiresConfirmation()
|
|
->modalHeading(__('localization.review.acknowledge_review_heading'))
|
|
->modalDescription(__('localization.review.acknowledge_review_description'))
|
|
->modalSubmitActionLabel(__('localization.review.acknowledge_review_confirm'))
|
|
->form([
|
|
Textarea::make('comment')
|
|
->label(__('localization.review.acknowledge_review_comment'))
|
|
->rows(4)
|
|
->maxLength(2000),
|
|
])
|
|
->action(function (array $data): void {
|
|
$actor = auth()->user();
|
|
$tenant = $this->latestReleasedTenant();
|
|
$review = $tenant instanceof ManagedEnvironment
|
|
? $this->latestPublishedReview($tenant)
|
|
: null;
|
|
|
|
if (! $actor instanceof User || ! $tenant instanceof ManagedEnvironment || ! $review instanceof EnvironmentReview) {
|
|
Notification::make()
|
|
->title(__('localization.review.acknowledge_review_unavailable'))
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
app(EnvironmentReviewAcknowledgementService::class)->acknowledge(
|
|
tenant: $tenant,
|
|
review: $review,
|
|
actor: $actor,
|
|
comment: is_string($data['comment'] ?? null) ? (string) $data['comment'] : null,
|
|
);
|
|
} catch (\Throwable $throwable) {
|
|
Notification::make()
|
|
->title(__('localization.review.acknowledge_review_failed'))
|
|
->body($throwable->getMessage())
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
Notification::make()
|
|
->title(__('localization.review.review_acknowledged'))
|
|
->success()
|
|
->send();
|
|
});
|
|
}
|
|
|
|
public function createNextReviewAction(): Action
|
|
{
|
|
return UiEnforcement::forAction(
|
|
Action::make('create_next_review')
|
|
->label(__('localization.review.create_next_review'))
|
|
->icon('heroicon-o-document-duplicate')
|
|
->color('primary')
|
|
->record(fn (): ?EnvironmentReview => $this->workspaceLifecycleReview())
|
|
->hidden(fn (): bool => ! ($this->workspaceLifecycleReview()?->isPublished() ?? false))
|
|
->requiresConfirmation()
|
|
->modalHeading(__('localization.review.create_next_review_heading'))
|
|
->modalDescription(__('localization.review.create_next_review_description'))
|
|
->modalSubmitActionLabel(__('localization.review.create_next_review_confirm'))
|
|
->action(function (): void {
|
|
$user = auth()->user();
|
|
$review = $this->workspaceLifecycleReview();
|
|
|
|
if (! $user instanceof User || ! $review instanceof EnvironmentReview) {
|
|
Notification::make()
|
|
->title(__('localization.review.create_next_review_unavailable'))
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$nextReview = app(EnvironmentReviewLifecycleService::class)->createNextReview($review, $user);
|
|
} catch (\Throwable $throwable) {
|
|
Notification::make()
|
|
->title(__('localization.review.create_next_review_failed'))
|
|
->body($throwable->getMessage())
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$tenant = $nextReview->tenant;
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
Notification::make()
|
|
->title(__('localization.review.create_next_review_unavailable'))
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$this->redirect(self::appendQuery(
|
|
EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $nextReview], $tenant),
|
|
array_filter([
|
|
'source_surface' => self::SOURCE_SURFACE,
|
|
'tenant_filter_id' => $this->currentTenantFilterId(),
|
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
));
|
|
}),
|
|
)
|
|
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
|
->preserveVisibility()
|
|
->apply();
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->query(fn (): Builder => $this->workspaceQuery())
|
|
->defaultSort('name')
|
|
->paginated(TablePaginationProfiles::customPage())
|
|
->persistFiltersInSession()
|
|
->persistSearchInSession()
|
|
->persistSortInSession()
|
|
->recordUrl(null)
|
|
->columns([
|
|
TextColumn::make('name')->label('Environment')->searchable(),
|
|
TextColumn::make('package_availability')
|
|
->label(__('localization.review.governance_package'))
|
|
->width('9rem')
|
|
->extraHeaderAttributes(['class' => 'whitespace-normal'])
|
|
->badge()
|
|
->getStateUsing(fn (ManagedEnvironment $record): string => $this->governancePackageAvailabilityLabel($record))
|
|
->color(fn (ManagedEnvironment $record): string => $this->governancePackageAvailabilityColor($record))
|
|
->tooltip(fn (ManagedEnvironment $record): string => $this->governancePackageAvailability($record)['description']),
|
|
TextColumn::make('latest_review')
|
|
->label(__('localization.review.status'))
|
|
->width('9rem')
|
|
->badge()
|
|
->getStateUsing(fn (ManagedEnvironment $record): string => $this->latestReviewStateLabel($record))
|
|
->color(fn (ManagedEnvironment $record): string => $this->latestReviewStateColor($record)),
|
|
TextColumn::make('evidence_proof_state')
|
|
->label(__('localization.review.evidence_status'))
|
|
->width('8rem')
|
|
->badge()
|
|
->getStateUsing(fn (ManagedEnvironment $record): string => $this->evidenceStatusLabel($record))
|
|
->color(fn (ManagedEnvironment $record): string => $this->evidenceStatusColor($record)),
|
|
TextColumn::make('recommended_next_action')
|
|
->label(__('localization.review.next_step'))
|
|
->width('10rem')
|
|
->extraHeaderAttributes(['class' => 'whitespace-normal'])
|
|
->getStateUsing(fn (ManagedEnvironment $record): string => $this->controlRecommendedNextAction($record))
|
|
->wrap(),
|
|
TextColumn::make('open_review')
|
|
->label(__('localization.review.open'))
|
|
->width('8rem')
|
|
->getStateUsing(fn (): string => __('localization.review.open_review'))
|
|
->url(fn (ManagedEnvironment $record): ?string => $this->latestReviewUrl($record))
|
|
->color('primary'),
|
|
])
|
|
->filters([
|
|
SelectFilter::make('managed_environment_id')
|
|
->label('Environment')
|
|
->options(fn (): array => $this->tenantFilterOptions())
|
|
->default(fn (): ?string => $this->defaultTenantFilter())
|
|
->query(function (Builder $query, array $data): Builder {
|
|
$tenantId = $data['value'] ?? null;
|
|
|
|
return is_numeric($tenantId)
|
|
? $query->whereKey((int) $tenantId)
|
|
: $query;
|
|
})
|
|
->searchable(),
|
|
])
|
|
->actions([])
|
|
->bulkActions([])
|
|
->emptyStateHeading(fn (): string => $this->workspaceEmptyStateHeading())
|
|
->emptyStateDescription(fn (): string => $this->workspaceEmptyStateDescription())
|
|
->emptyStateActions([
|
|
Action::make('clear_filters_empty')
|
|
->label(__('localization.review.clear_filters'))
|
|
->icon('heroicon-o-x-mark')
|
|
->color('gray')
|
|
->visible(fn (): bool => $this->hasActiveFilters())
|
|
->action(fn (): mixed => $this->clearWorkspaceFilters()),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, ManagedEnvironment>
|
|
*/
|
|
public 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 = app(EnvironmentReviewRegisterService::class)->authorizedTenants($user, $workspace);
|
|
}
|
|
|
|
public function activeEnvironmentFilterLabel(): ?string
|
|
{
|
|
return $this->filteredTenant()?->name;
|
|
}
|
|
|
|
/**
|
|
* @return array{label: string, clear_url: string}|null
|
|
*/
|
|
public function environmentFilterChip(): ?array
|
|
{
|
|
$tenant = $this->filteredTenant();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'label' => (string) $tenant->name,
|
|
'clear_url' => $this->cleanWorkspaceHubUrl(static::getUrl(panel: 'admin')),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function latestReviewConsumptionPayload(): ?array
|
|
{
|
|
$tenant = $this->latestReleasedTenant();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return null;
|
|
}
|
|
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return null;
|
|
}
|
|
|
|
$review->loadMissing([
|
|
'currentExportReviewPack.operationRun',
|
|
'evidenceSnapshot.operationRun',
|
|
'operationRun',
|
|
'supersededByReview.sections',
|
|
]);
|
|
|
|
$publishedAt = $review->published_at ?? $review->generated_at ?? $review->created_at;
|
|
$packageAvailability = $this->governancePackageAvailability($tenant);
|
|
$downloadUrl = $this->reviewPackDownloadUrl($review, $tenant);
|
|
$reviewUrl = $this->latestReviewUrl($tenant);
|
|
$evidenceUrl = $this->evidenceSnapshotUrlForReview($review, $tenant);
|
|
$outputReadiness = $this->reviewPackOutputReadinessForReview($review);
|
|
$outputGuidance = $this->reviewOutputGuidanceForReview(
|
|
review: $review,
|
|
downloadUrl: $downloadUrl,
|
|
reviewUrl: $reviewUrl,
|
|
evidenceUrl: $evidenceUrl,
|
|
);
|
|
$resolutionCase = $this->reviewOutputResolutionCaseForReview($review, $outputGuidance);
|
|
$decision = $this->decisionSummaryForReview($review);
|
|
$acceptedRisks = $this->acceptedRisksForReview($review);
|
|
$hasAcceptedRiskFollowUp = $this->acceptedRiskFollowUpRequiredForReview($review);
|
|
$findingPanel = $this->findingPanelForReview($tenant);
|
|
$evidencePath = $this->evidencePathForReview($review, $tenant, $packageAvailability, $downloadUrl, $decision, $acceptedRisks);
|
|
$readiness = $this->reviewReadinessForTenant(
|
|
tenant: $tenant,
|
|
review: $review,
|
|
packageAvailability: $packageAvailability,
|
|
outputReadiness: $outputReadiness,
|
|
outputGuidance: $outputGuidance,
|
|
resolutionCase: $resolutionCase,
|
|
downloadUrl: $downloadUrl,
|
|
reviewUrl: $reviewUrl,
|
|
evidenceUrl: $evidenceUrl,
|
|
);
|
|
|
|
return [
|
|
'scope' => $this->reviewScopePayload($tenant),
|
|
'latest' => [
|
|
'review_label' => __('localization.review.released_review_for_environment', [
|
|
'environment' => $tenant->name,
|
|
]),
|
|
'environment_label' => $tenant->name,
|
|
'status_label' => $this->latestReviewStateLabel($tenant),
|
|
'status_color' => $this->latestReviewStateColor($tenant),
|
|
'published_label' => $publishedAt instanceof \DateTimeInterface
|
|
? $publishedAt->format('M j, Y H:i')
|
|
: __('localization.review.unavailable'),
|
|
'package_label' => $packageAvailability['label'],
|
|
'package_badge_label' => $this->governancePackageAvailabilityLabel($tenant),
|
|
'package_color' => $this->governancePackageAvailabilityColor($tenant),
|
|
'package_description' => $packageAvailability['description'],
|
|
'primary_action_label' => $downloadUrl !== null
|
|
? $outputGuidance['qualified_download_label']
|
|
: __('localization.review.open_latest_review'),
|
|
'primary_action_url' => $downloadUrl ?? $reviewUrl,
|
|
'primary_action_icon' => $downloadUrl !== null
|
|
? 'heroicon-o-arrow-down-tray'
|
|
: 'heroicon-o-arrow-top-right-on-square',
|
|
'secondary_action_label' => $readiness['secondary_action_label'],
|
|
'secondary_action_url' => $readiness['secondary_action_url'],
|
|
'secondary_action_icon' => 'heroicon-o-arrow-top-right-on-square',
|
|
],
|
|
'readiness' => $readiness,
|
|
'readiness_flow' => $this->reviewConsumptionFlowForReview($tenant, $review, $packageAvailability, $downloadUrl, $outputReadiness),
|
|
'finding_panel' => $findingPanel,
|
|
'acknowledgement' => $this->reviewAcknowledgementPayloadForReview($tenant, $review, $packageAvailability, $downloadUrl),
|
|
'decision' => $decision,
|
|
'accepted_risks' => $acceptedRisks,
|
|
'accepted_risk_panel' => $this->acceptedRiskPanelForReview($review, $tenant),
|
|
'evidence_basis' => $this->evidenceBasisForReview($review, $packageAvailability, $outputReadiness),
|
|
'evidence_path' => $evidencePath,
|
|
'aside_evidence_path' => $this->asideEvidencePath($evidencePath),
|
|
'review_pack_panel' => $this->reviewPackPanelForReview($review, $tenant, $packageAvailability, $downloadUrl, $outputReadiness),
|
|
'follow_ups' => $this->customerSafeFollowUpsForReview($decision),
|
|
'diagnostics' => $this->diagnosticsDisclosureForReview(),
|
|
'disclosure_rules' => $this->disclosureRuleRows(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{state:string,label:string,description:string} $packageAvailability
|
|
* @return array{
|
|
* state: 'not_available'|'required'|'acknowledged'|'re_ack_required',
|
|
* status_label: string,
|
|
* status_color: string,
|
|
* reason: string,
|
|
* impact: string,
|
|
* action_name: string|null,
|
|
* action_label: string,
|
|
* action_color: string,
|
|
* action_disabled: bool,
|
|
* action_helper: string|null,
|
|
* acknowledged_at_label: string|null,
|
|
* acknowledged_by_label: string|null,
|
|
* comment: string|null,
|
|
* basis: list<array{label:string,value:string,color:string}>
|
|
* }
|
|
*/
|
|
private function reviewAcknowledgementPayloadForReview(
|
|
ManagedEnvironment $tenant,
|
|
EnvironmentReview $review,
|
|
array $packageAvailability,
|
|
?string $downloadUrl,
|
|
): array {
|
|
$actor = auth()->user();
|
|
|
|
$canAcknowledge = $actor instanceof User
|
|
&& $actor->canAccessTenant($tenant)
|
|
&& $actor->can(Capabilities::ENVIRONMENT_REVIEW_ACKNOWLEDGE, $tenant);
|
|
|
|
$ack = EnvironmentReviewAcknowledgement::query()
|
|
->with(['acknowledgedByUser'])
|
|
->where('environment_review_id', (int) $review->getKey())
|
|
->where('managed_environment_id', (int) $review->managed_environment_id)
|
|
->where('workspace_id', (int) $review->workspace_id)
|
|
->first();
|
|
|
|
$currentReviewPackId = is_numeric($review->current_export_review_pack_id)
|
|
? (int) $review->current_export_review_pack_id
|
|
: null;
|
|
$currentEvidenceSnapshotId = is_numeric($review->evidence_snapshot_id)
|
|
? (int) $review->evidence_snapshot_id
|
|
: null;
|
|
|
|
$reviewPackProof = $this->reviewPackProofForReview($packageAvailability, $downloadUrl);
|
|
$evidenceState = $this->evidenceStatusState($tenant);
|
|
|
|
$basis = [
|
|
[
|
|
'label' => __('localization.review.review_pack'),
|
|
'value' => $reviewPackProof['label'],
|
|
'color' => $reviewPackProof['color'],
|
|
],
|
|
[
|
|
'label' => __('localization.review.evidence'),
|
|
'value' => $this->evidenceStatusLabelForState($evidenceState),
|
|
'color' => $this->evidenceStatusColorForState($evidenceState),
|
|
],
|
|
];
|
|
|
|
if (! $review->isPublished()) {
|
|
return [
|
|
'state' => 'not_available',
|
|
'status_label' => __('localization.review.acknowledgement_not_available'),
|
|
'status_color' => 'gray',
|
|
'reason' => __('localization.review.acknowledgement_not_available_reason'),
|
|
'impact' => __('localization.review.acknowledgement_not_available_impact'),
|
|
'action_name' => null,
|
|
'action_label' => __('localization.review.review_accepted_risks'),
|
|
'action_color' => 'gray',
|
|
'action_disabled' => true,
|
|
'action_helper' => null,
|
|
'acknowledged_at_label' => null,
|
|
'acknowledged_by_label' => null,
|
|
'comment' => null,
|
|
'basis' => $basis,
|
|
];
|
|
}
|
|
|
|
if (! $ack instanceof EnvironmentReviewAcknowledgement) {
|
|
return [
|
|
'state' => 'required',
|
|
'status_label' => __('localization.review.acknowledgement_required'),
|
|
'status_color' => 'warning',
|
|
'reason' => __('localization.review.acknowledgement_required_reason'),
|
|
'impact' => __('localization.review.acknowledgement_required_impact'),
|
|
'action_name' => 'acknowledgeReview',
|
|
'action_label' => __('localization.review.acknowledge_review'),
|
|
'action_color' => 'primary',
|
|
'action_disabled' => ! $canAcknowledge,
|
|
'action_helper' => ! $canAcknowledge
|
|
? __('localization.review.acknowledgement_requires_permission')
|
|
: null,
|
|
'acknowledged_at_label' => null,
|
|
'acknowledged_by_label' => null,
|
|
'comment' => null,
|
|
'basis' => $basis,
|
|
];
|
|
}
|
|
|
|
$basisDriftDetected = false;
|
|
|
|
if (is_numeric($ack->review_pack_id) && is_int($currentReviewPackId) && (int) $ack->review_pack_id !== $currentReviewPackId) {
|
|
$basisDriftDetected = true;
|
|
}
|
|
|
|
if (is_numeric($ack->evidence_snapshot_id) && is_int($currentEvidenceSnapshotId) && (int) $ack->evidence_snapshot_id !== $currentEvidenceSnapshotId) {
|
|
$basisDriftDetected = true;
|
|
}
|
|
|
|
$acknowledgedAtLabel = $ack->acknowledged_at instanceof \DateTimeInterface
|
|
? $ack->acknowledged_at->format('M j, Y H:i')
|
|
: null;
|
|
|
|
$acknowledgedByLabel = $ack->acknowledgedByUser instanceof User
|
|
? (string) $ack->acknowledgedByUser->name
|
|
: null;
|
|
|
|
$comment = is_string($ack->comment) ? trim($ack->comment) : null;
|
|
$comment = filled($comment) ? $comment : null;
|
|
|
|
if ($basisDriftDetected) {
|
|
return [
|
|
'state' => 're_ack_required',
|
|
'status_label' => __('localization.review.acknowledgement_re_ack_required'),
|
|
'status_color' => 'warning',
|
|
'reason' => __('localization.review.acknowledgement_re_ack_required_reason'),
|
|
'impact' => __('localization.review.acknowledgement_re_ack_required_impact'),
|
|
'action_name' => 'acknowledgeReview',
|
|
'action_label' => __('localization.review.re_acknowledge_review'),
|
|
'action_color' => 'warning',
|
|
'action_disabled' => ! $canAcknowledge,
|
|
'action_helper' => ! $canAcknowledge
|
|
? __('localization.review.acknowledgement_requires_permission')
|
|
: null,
|
|
'acknowledged_at_label' => $acknowledgedAtLabel,
|
|
'acknowledged_by_label' => $acknowledgedByLabel,
|
|
'comment' => $comment,
|
|
'basis' => $basis,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'state' => 'acknowledged',
|
|
'status_label' => __('localization.review.review_acknowledged'),
|
|
'status_color' => 'success',
|
|
'reason' => __('localization.review.acknowledgement_recorded_reason'),
|
|
'impact' => __('localization.review.acknowledgement_recorded_impact'),
|
|
'action_name' => null,
|
|
'action_label' => __('localization.review.review_accepted_risks'),
|
|
'action_color' => 'gray',
|
|
'action_disabled' => true,
|
|
'action_helper' => null,
|
|
'acknowledged_at_label' => $acknowledgedAtLabel,
|
|
'acknowledged_by_label' => $acknowledgedByLabel,
|
|
'comment' => $comment,
|
|
'basis' => $basis,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{label:string,description:string,is_filtered:bool}
|
|
*/
|
|
private function reviewScopePayload(ManagedEnvironment $tenant): array
|
|
{
|
|
$filteredTenant = $this->filteredTenant();
|
|
|
|
if ($filteredTenant instanceof ManagedEnvironment) {
|
|
return [
|
|
'label' => __('localization.review.customer_workspace_scope_environment_filtered', [
|
|
'environment' => $filteredTenant->name,
|
|
]),
|
|
'description' => __('localization.review.customer_workspace_scope_environment_filtered_description'),
|
|
'is_filtered' => true,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'label' => __('localization.review.customer_workspace_scope_workspace_wide'),
|
|
'description' => __('localization.review.customer_workspace_scope_workspace_wide_description', [
|
|
'environment' => $tenant->name,
|
|
]),
|
|
'is_filtered' => false,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{state:string,label:string,description:string} $packageAvailability
|
|
* @param array<string, mixed> $outputReadiness
|
|
* @param array<string, mixed> $outputGuidance
|
|
* @return array{
|
|
* question:string,
|
|
* label:string,
|
|
* color:string,
|
|
* boundary_label:string,
|
|
* boundary_color:string,
|
|
* reason:string,
|
|
* impact:string,
|
|
* primary_action_label:string,
|
|
* primary_action_url:?string,
|
|
* primary_action_icon:string,
|
|
* secondary_action_label:?string,
|
|
* secondary_action_url:?string,
|
|
* secondary_actions:list<array{key:string,label:string,type:string,url:?string,icon:string,kind:string,capability:?string,requires_confirmation:bool,audit_event:?string,operation_run_type:?string,disabled_reason:?string}>,
|
|
* resolution_case:array<string, mixed>,
|
|
* output_guidance:array<string, mixed>
|
|
* }
|
|
*/
|
|
private function reviewReadinessForTenant(
|
|
ManagedEnvironment $tenant,
|
|
EnvironmentReview $review,
|
|
array $packageAvailability,
|
|
array $outputReadiness,
|
|
array $outputGuidance,
|
|
array $resolutionCase,
|
|
?string $downloadUrl,
|
|
?string $reviewUrl,
|
|
?string $evidenceUrl,
|
|
): array {
|
|
$hasAcceptedRiskFollowUp = $this->acceptedRiskFollowUpRequiredForReview($review);
|
|
$findingPanel = $this->findingPanelForReview($tenant);
|
|
$hasFindingFollowUp = $findingPanel['open_count'] > 0;
|
|
$effectiveState = $this->effectiveWorkspaceReadinessState(
|
|
$outputReadiness,
|
|
$hasFindingFollowUp,
|
|
$hasAcceptedRiskFollowUp,
|
|
);
|
|
$reasonCode = $hasFindingFollowUp
|
|
? 'findings_follow_up_required'
|
|
: ($hasAcceptedRiskFollowUp ? 'accepted_risk_follow_up_required' : (string) ($outputReadiness['primary_reason'] ?? 'customer_safe_ready'));
|
|
$actions = $this->workspaceReadinessActions(
|
|
state: $effectiveState,
|
|
reasonCode: $reasonCode,
|
|
downloadUrl: $downloadUrl,
|
|
reviewUrl: $reviewUrl,
|
|
evidenceUrl: $evidenceUrl,
|
|
);
|
|
$followUpOverride = in_array($reasonCode, ['findings_follow_up_required', 'accepted_risk_follow_up_required'], true);
|
|
$presentedResolutionCase = $followUpOverride
|
|
? $this->workspaceFollowUpResolutionCase(
|
|
baseCase: $resolutionCase,
|
|
effectiveState: $effectiveState,
|
|
reasonCode: $reasonCode,
|
|
outputReadiness: $outputReadiness,
|
|
findingPanel: $findingPanel,
|
|
packageAvailability: $packageAvailability,
|
|
actions: $actions,
|
|
)
|
|
: $resolutionCase;
|
|
$presentedResolutionCase = $this->decorateSuccessorResolutionCase($presentedResolutionCase, $review);
|
|
$primaryAction = is_array($presentedResolutionCase['primary_action'] ?? null) ? $presentedResolutionCase['primary_action'] : null;
|
|
$secondaryActions = is_array($presentedResolutionCase['secondary_actions'] ?? null) ? $presentedResolutionCase['secondary_actions'] : [];
|
|
|
|
return [
|
|
'question' => __('localization.review.review_pack_output_status'),
|
|
'label' => $followUpOverride
|
|
? $this->workspaceReadinessLabel($effectiveState)
|
|
: (string) ($outputGuidance['label'] ?? $this->workspaceReadinessLabel($effectiveState)),
|
|
'color' => $followUpOverride
|
|
? $this->workspaceReadinessColor($effectiveState)
|
|
: (string) ($outputGuidance['color'] ?? $this->workspaceReadinessColor($effectiveState)),
|
|
'boundary_label' => $followUpOverride
|
|
? $this->workspaceBoundaryLabel((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review'))
|
|
: (string) ($outputGuidance['boundary_label'] ?? $this->workspaceBoundaryLabel((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review'))),
|
|
'boundary_color' => $followUpOverride
|
|
? $this->workspaceBoundaryColor((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review'))
|
|
: (string) ($outputGuidance['boundary_color'] ?? $this->workspaceBoundaryColor((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review'))),
|
|
'reason' => (string) ($presentedResolutionCase['reason'] ?? $outputGuidance['primary_reason'] ?? $packageAvailability['description']),
|
|
'impact' => (string) ($presentedResolutionCase['impact'] ?? $outputGuidance['impact'] ?? $this->workspaceReadinessImpact(state: $effectiveState, reasonCode: $reasonCode)),
|
|
'primary_action_label' => (string) ($primaryAction['label'] ?? $actions['primary_label']),
|
|
'primary_action_url' => $primaryAction['url'] ?? $actions['primary_url'],
|
|
'primary_action_icon' => (string) ($primaryAction['icon'] ?? $actions['primary_icon']),
|
|
'secondary_action_label' => $secondaryActions[0]['label'] ?? null,
|
|
'secondary_action_url' => $secondaryActions[0]['url'] ?? null,
|
|
'secondary_actions' => $secondaryActions,
|
|
'resolution_case' => $presentedResolutionCase,
|
|
'output_guidance' => array_replace($outputGuidance, [
|
|
'action_help' => $this->workspaceActionHelpForResolutionCase($presentedResolutionCase, $outputGuidance),
|
|
]),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{state:string,label:string,description:string} $packageAvailability
|
|
* @param array<string, mixed> $outputReadiness
|
|
* @return list<array{title:string,label:string,color:string,description:string,is_current:bool}>
|
|
*/
|
|
private function reviewConsumptionFlowForReview(
|
|
ManagedEnvironment $tenant,
|
|
EnvironmentReview $review,
|
|
array $packageAvailability,
|
|
?string $downloadUrl,
|
|
array $outputReadiness,
|
|
): array {
|
|
$evidenceState = $this->evidenceStatusState($tenant);
|
|
$findingPanel = $this->findingPanelForReview($tenant);
|
|
$acceptedRisk = $this->acceptedRiskDimensionForReview($review, $tenant);
|
|
$hasAcceptedRiskFollowUp = $this->acceptedRiskFollowUpRequiredForReview($review);
|
|
$hasReadyPackage = $packageAvailability['state'] === 'available' && $downloadUrl !== null;
|
|
$hasMappedReviewData = $this->primaryControlSummary($tenant) !== null;
|
|
$workspaceState = $this->effectiveWorkspaceReadinessState(
|
|
$outputReadiness,
|
|
$findingPanel['open_count'] > 0,
|
|
$hasAcceptedRiskFollowUp,
|
|
);
|
|
$hasBlockingAttention = $findingPanel['open_count'] > 0
|
|
|| $hasAcceptedRiskFollowUp
|
|
|| $evidenceState !== 'available'
|
|
|| ! $hasMappedReviewData
|
|
|| $workspaceState !== ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY;
|
|
|
|
$customerOutputLabel = match (true) {
|
|
$hasReadyPackage && ! $hasBlockingAttention => __('localization.review.ready'),
|
|
$workspaceState === ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY || ! $hasReadyPackage => __('localization.review.not_ready'),
|
|
$hasReadyPackage => __('localization.review.needs_review'),
|
|
default => __('localization.review.not_ready'),
|
|
};
|
|
$customerOutputColor = match (true) {
|
|
$hasReadyPackage && ! $hasBlockingAttention => 'success',
|
|
$workspaceState === ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY || ! $hasReadyPackage => 'gray',
|
|
$hasReadyPackage => 'warning',
|
|
default => 'gray',
|
|
};
|
|
$customerOutputDescription = match (true) {
|
|
$hasReadyPackage && ! $hasBlockingAttention => __('localization.review.customer_output_ready_description'),
|
|
$hasReadyPackage => __('localization.review.customer_output_needs_review_description'),
|
|
default => __('localization.review.customer_output_not_ready_description'),
|
|
};
|
|
|
|
return [
|
|
[
|
|
'title' => __('localization.review.review_data'),
|
|
'label' => __('localization.review.available'),
|
|
'color' => 'success',
|
|
'description' => __('localization.review.review_data_available_description'),
|
|
'is_current' => false,
|
|
],
|
|
[
|
|
'title' => __('localization.review.evidence'),
|
|
'label' => $this->evidenceStatusLabelForState($evidenceState),
|
|
'color' => $this->evidenceStatusColorForState($evidenceState),
|
|
'description' => $this->evidenceDimensionDescription($evidenceState),
|
|
'is_current' => $evidenceState !== 'available',
|
|
],
|
|
[
|
|
'title' => __('localization.review.findings_triaged'),
|
|
'label' => $findingPanel['status_label'],
|
|
'color' => $findingPanel['status_color'],
|
|
'description' => $findingPanel['summary'],
|
|
'is_current' => $findingPanel['open_count'] > 0,
|
|
],
|
|
[
|
|
'title' => __('localization.review.accepted_risks_reviewed'),
|
|
'label' => $acceptedRisk['label'],
|
|
'color' => $acceptedRisk['color'],
|
|
'description' => $acceptedRisk['description'],
|
|
'is_current' => $this->acceptedRiskFollowUpRequiredForReview($review),
|
|
],
|
|
[
|
|
'title' => __('localization.review.review_pack'),
|
|
'label' => $packageAvailability['label'],
|
|
'color' => $this->governancePackageAvailabilityColor($tenant),
|
|
'description' => $this->reviewPackDimensionDescription($packageAvailability),
|
|
'is_current' => $packageAvailability['state'] !== 'available',
|
|
],
|
|
[
|
|
'title' => __('localization.review.customer_output'),
|
|
'label' => $customerOutputLabel,
|
|
'color' => $customerOutputColor,
|
|
'description' => $customerOutputDescription,
|
|
'is_current' => ! ($hasReadyPackage && ! $hasBlockingAttention),
|
|
],
|
|
];
|
|
}
|
|
|
|
private function evidenceDimensionDescription(string $state): string
|
|
{
|
|
return match ($state) {
|
|
'available' => __('localization.review.evidence_dimension_available_description'),
|
|
'expired' => __('localization.review.evidence_dimension_expired_description'),
|
|
'restricted' => __('localization.review.evidence_dimension_restricted_description'),
|
|
default => __('localization.review.evidence_dimension_unavailable_description'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array{state:string,label:string,description:string} $packageAvailability
|
|
*/
|
|
private function reviewPackDimensionDescription(array $packageAvailability): string
|
|
{
|
|
return match ($packageAvailability['state']) {
|
|
'available' => __('localization.review.review_pack_dimension_available_description'),
|
|
'not_available' => __('localization.review.review_pack_dimension_not_generated_description'),
|
|
'preparing' => __('localization.review.review_pack_dimension_preparing_description'),
|
|
'expired' => __('localization.review.review_pack_dimension_expired_description'),
|
|
default => __('localization.review.review_pack_dimension_unavailable_description'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array{label:string,color:string,description:string}
|
|
*/
|
|
private function acceptedRiskDimensionForReview(EnvironmentReview $review, ManagedEnvironment $tenant): array
|
|
{
|
|
$acceptedRisks = $this->acceptedRisksForReview($review);
|
|
$exceptionCount = $this->acceptedRiskExceptionsForTenant($tenant)->count();
|
|
$acceptedRiskCount = max($acceptedRisks['count'], $exceptionCount);
|
|
$hasFollowUp = $this->acceptedRiskFollowUpRequiredForReview($review);
|
|
|
|
if ($hasFollowUp) {
|
|
return [
|
|
'label' => __('localization.review.accepted_risk_follow_up'),
|
|
'color' => 'warning',
|
|
'description' => __('localization.review.accepted_risk_dimension_follow_up_description'),
|
|
];
|
|
}
|
|
|
|
if ($acceptedRiskCount === 0) {
|
|
return [
|
|
'label' => __('localization.review.accepted_risk_no_action_needed'),
|
|
'color' => 'gray',
|
|
'description' => __('localization.review.accepted_risk_dimension_no_action_description'),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'label' => __('localization.review.accepted_risk_on_record', ['count' => $acceptedRiskCount]),
|
|
'color' => 'info',
|
|
'description' => __('localization.review.accepted_risk_dimension_on_record_description'),
|
|
];
|
|
}
|
|
|
|
private function acceptedRiskFollowUpRequiredForReview(EnvironmentReview $review): bool
|
|
{
|
|
$package = $this->governancePackageSummaryForReview($review);
|
|
$decisionSummary = is_array($package['decision_summary'] ?? null) ? $package['decision_summary'] : [];
|
|
|
|
if ((string) ($decisionSummary['status'] ?? '') === 'requires_awareness') {
|
|
return true;
|
|
}
|
|
|
|
$acceptedEntries = collect($package['accepted_risks'] ?? [])
|
|
->filter(static fn (mixed $entry): bool => is_array($entry));
|
|
$decisionEntries = collect($package['governance_decisions'] ?? [])
|
|
->filter(static fn (mixed $entry): bool => is_array($entry));
|
|
|
|
if ($acceptedEntries
|
|
->merge($decisionEntries)
|
|
->contains(static fn (array $entry): bool => in_array(
|
|
(string) ($entry['governance_state'] ?? ''),
|
|
self::ACCEPTED_RISK_FOLLOW_UP_STATES,
|
|
true,
|
|
))) {
|
|
return true;
|
|
}
|
|
|
|
return FindingException::query()
|
|
->where('workspace_id', (int) $review->workspace_id)
|
|
->where('managed_environment_id', (int) $review->managed_environment_id)
|
|
->current()
|
|
->whereIn('current_validity_state', [
|
|
FindingException::VALIDITY_EXPIRING,
|
|
FindingException::VALIDITY_EXPIRED,
|
|
FindingException::VALIDITY_REVOKED,
|
|
FindingException::VALIDITY_MISSING_SUPPORT,
|
|
])
|
|
->exists();
|
|
}
|
|
|
|
/**
|
|
* @param array{state:string,label:string,description:string} $packageAvailability
|
|
* @param array<string, mixed> $decision
|
|
* @param array{count:int,entries:list<array<string, string>>,empty_state:string} $acceptedRisks
|
|
* @return list<array{key:string,title:string,label:string,color:string,description:string,action_label:?string,action_url:?string}>
|
|
*/
|
|
private function evidencePathForReview(
|
|
EnvironmentReview $review,
|
|
ManagedEnvironment $tenant,
|
|
array $packageAvailability,
|
|
?string $downloadUrl,
|
|
array $decision,
|
|
array $acceptedRisks,
|
|
): array {
|
|
return [
|
|
$this->evidenceSnapshotProofForReview($review, $tenant),
|
|
$this->reviewPackProofForReview($packageAvailability, $downloadUrl),
|
|
$this->decisionTrailProofForReview($decision),
|
|
$this->acceptedRiskProofForReview($acceptedRisks),
|
|
$this->operationProofForReview($review, $tenant),
|
|
$this->exportArtifactProofForReview($packageAvailability, $downloadUrl),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<array{key:string,title:string,label:string,color:string,description:string,action_label:?string,action_url:?string}> $evidencePath
|
|
* @return list<array{key:string,title:string,label:string,color:string,description:string,detail:string,action_label:?string,action_url:?string}>
|
|
*/
|
|
private function asideEvidencePath(array $evidencePath): array
|
|
{
|
|
$asideKeys = [
|
|
'evidence_snapshot',
|
|
'review_pack',
|
|
'decision_trail',
|
|
'operation_proof',
|
|
];
|
|
|
|
return collect($evidencePath)
|
|
->filter(static fn (array $proof): bool => in_array($proof['key'], $asideKeys, true))
|
|
->map(fn (array $proof): array => array_replace($proof, [
|
|
'label' => $this->asideEvidencePathLabel($proof),
|
|
'detail' => $this->asideEvidencePathDetail($proof),
|
|
]))
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array{key:string,label:string,color:string} $proof
|
|
*/
|
|
private function asideEvidencePathLabel(array $proof): string
|
|
{
|
|
if ($proof['key'] !== 'decision_trail') {
|
|
return $proof['label'];
|
|
}
|
|
|
|
return match ($proof['color']) {
|
|
'success', 'info' => __('localization.review.available'),
|
|
'warning' => __('localization.review.limited'),
|
|
default => __('localization.review.unavailable'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array{key:string,title:string,description:string} $proof
|
|
*/
|
|
private function asideEvidencePathDetail(array $proof): string
|
|
{
|
|
$description = (string) $proof['description'];
|
|
$titlePrefix = trim((string) $proof['title']).' ';
|
|
|
|
if (str_starts_with($description, $titlePrefix)) {
|
|
return Str::ucfirst(Str::replaceStart($titlePrefix, '', $description));
|
|
}
|
|
|
|
return $description;
|
|
}
|
|
|
|
/**
|
|
* @return array{key:string,title:string,label:string,color:string,description:string,action_label:?string,action_url:?string}
|
|
*/
|
|
private function evidenceSnapshotProofForReview(EnvironmentReview $review, ManagedEnvironment $tenant): array
|
|
{
|
|
$snapshot = $review->evidenceSnapshot;
|
|
$state = $this->evidenceStatusState($tenant);
|
|
$url = $this->evidenceSnapshotUrlForReview($review, $tenant);
|
|
|
|
return [
|
|
'key' => 'evidence_snapshot',
|
|
'title' => __('localization.review.evidence_snapshot'),
|
|
'label' => $this->evidenceStatusLabelForState($state),
|
|
'color' => $this->evidenceStatusColorForState($state),
|
|
'description' => $snapshot instanceof EvidenceSnapshot && $snapshot->generated_at !== null
|
|
? __('localization.review.evidence_snapshot_available_description', [
|
|
'date' => $snapshot->generated_at->format('M j, Y H:i'),
|
|
])
|
|
: __('localization.review.evidence_proof_absent'),
|
|
'action_label' => $url !== null ? __('localization.review.view_evidence_snapshot') : null,
|
|
'action_url' => $url,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{state:string,label:string,description:string} $packageAvailability
|
|
* @return array{key:string,title:string,label:string,color:string,description:string,action_label:?string,action_url:?string}
|
|
*/
|
|
private function reviewPackProofForReview(array $packageAvailability, ?string $downloadUrl): array
|
|
{
|
|
return [
|
|
'key' => 'review_pack',
|
|
'title' => __('localization.review.review_pack'),
|
|
'label' => $packageAvailability['label'],
|
|
'color' => match ($packageAvailability['state']) {
|
|
'available' => 'success',
|
|
'preparing' => 'warning',
|
|
'expired', 'unavailable' => 'danger',
|
|
default => 'gray',
|
|
},
|
|
'description' => $packageAvailability['description'],
|
|
'action_label' => null,
|
|
'action_url' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $decision
|
|
* @return array{key:string,title:string,label:string,color:string,description:string,action_label:?string,action_url:?string}
|
|
*/
|
|
private function decisionTrailProofForReview(array $decision): array
|
|
{
|
|
return [
|
|
'key' => 'decision_trail',
|
|
'title' => __('localization.review.decision_trail'),
|
|
'label' => (string) $decision['label'],
|
|
'color' => (string) $decision['color'],
|
|
'description' => (string) $decision['summary'],
|
|
'action_label' => null,
|
|
'action_url' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{count:int,entries:list<array<string, string>>,empty_state:string} $acceptedRisks
|
|
* @return array{key:string,title:string,label:string,color:string,description:string,action_label:?string,action_url:?string}
|
|
*/
|
|
private function acceptedRiskProofForReview(array $acceptedRisks): array
|
|
{
|
|
return [
|
|
'key' => 'accepted_risk_records',
|
|
'title' => __('localization.review.accepted_risk_records'),
|
|
'label' => $acceptedRisks['count'] === 0
|
|
? __('localization.review.accepted_risk_none')
|
|
: __('localization.review.accepted_risk_on_record', ['count' => $acceptedRisks['count']]),
|
|
'color' => $acceptedRisks['count'] === 0 ? 'success' : 'info',
|
|
'description' => $acceptedRisks['count'] === 0
|
|
? $acceptedRisks['empty_state']
|
|
: __('localization.review.accepted_risk_records_description'),
|
|
'action_label' => null,
|
|
'action_url' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{key:string,title:string,label:string,color:string,description:string,action_label:?string,action_url:?string}
|
|
*/
|
|
private function operationProofForReview(EnvironmentReview $review, ManagedEnvironment $tenant): array
|
|
{
|
|
$run = collect([
|
|
$review->operationRun,
|
|
$review->evidenceSnapshot?->operationRun,
|
|
$review->currentExportReviewPack?->operationRun,
|
|
])->first(fn (mixed $candidate): bool => $candidate instanceof OperationRun);
|
|
|
|
if ($run instanceof OperationRun) {
|
|
$initiator = is_string($run->initiator_name) && trim($run->initiator_name) !== ''
|
|
? trim($run->initiator_name)
|
|
: null;
|
|
|
|
return [
|
|
'key' => 'operation_proof',
|
|
'title' => __('localization.review.operation_proof'),
|
|
'label' => __('localization.review.available'),
|
|
'color' => 'info',
|
|
'description' => $initiator === null
|
|
? __('localization.review.operation_proof_available_description')
|
|
: __('localization.review.operation_proof_available_with_initiator_description', ['initiator' => $initiator]),
|
|
'action_label' => OperationRunLinks::openLabel(),
|
|
'action_url' => OperationRunLinks::tenantlessView($run),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'key' => 'operation_proof',
|
|
'title' => __('localization.review.operation_proof'),
|
|
'label' => __('localization.review.unavailable'),
|
|
'color' => 'gray',
|
|
'description' => __('localization.review.operation_proof_unavailable_description'),
|
|
'action_label' => null,
|
|
'action_url' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{state:string,label:string,description:string} $packageAvailability
|
|
* @return array{key:string,title:string,label:string,color:string,description:string,action_label:?string,action_url:?string}
|
|
*/
|
|
private function exportArtifactProofForReview(array $packageAvailability, ?string $downloadUrl): array
|
|
{
|
|
return [
|
|
'key' => 'export_artifact',
|
|
'title' => __('localization.review.export_artifact'),
|
|
'label' => $downloadUrl !== null
|
|
? __('localization.review.available')
|
|
: $packageAvailability['label'],
|
|
'color' => $downloadUrl !== null ? 'success' : 'gray',
|
|
'description' => $downloadUrl !== null
|
|
? __('localization.review.export_artifact_available_description')
|
|
: __('localization.review.export_artifact_unavailable_description'),
|
|
'action_label' => null,
|
|
'action_url' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{status_label:string,status_color:string,summary:string,total_count:int,open_count:int,high_impact_count:int,items:list<array{label:string,value:string,color:string}>}
|
|
*/
|
|
private function findingPanelForReview(ManagedEnvironment $tenant): array
|
|
{
|
|
$baseQuery = Finding::query()
|
|
->where('workspace_id', (int) $tenant->workspace_id)
|
|
->where('managed_environment_id', (int) $tenant->getKey());
|
|
|
|
$total = (clone $baseQuery)->count();
|
|
$open = (clone $baseQuery)
|
|
->whereIn('status', Finding::openStatusesForQuery())
|
|
->count();
|
|
$highImpact = (clone $baseQuery)
|
|
->whereIn('status', Finding::openStatusesForQuery())
|
|
->whereIn('severity', Finding::highSeverityValues())
|
|
->count();
|
|
$accepted = (clone $baseQuery)
|
|
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
|
->count();
|
|
|
|
$summary = match (true) {
|
|
$open > 0 && $highImpact > 0 => __('localization.review.findings_high_impact_summary', [
|
|
'open' => trans_choice('localization.review.findings_open_attention_count', $open, ['count' => $open]),
|
|
'high' => trans_choice('localization.review.findings_high_impact_count_summary', $highImpact, ['count' => $highImpact]),
|
|
]),
|
|
$open > 0 => trans_choice('localization.review.findings_open_summary', $open, ['count' => $open]),
|
|
$total > 0 => __('localization.review.findings_no_open_summary', ['total' => $total]),
|
|
default => __('localization.review.findings_none_action_summary'),
|
|
};
|
|
|
|
return [
|
|
'status_label' => $open > 0
|
|
? __('localization.review.needs_review')
|
|
: __('localization.review.no_action_needed'),
|
|
'status_color' => match (true) {
|
|
$highImpact > 0 => 'danger',
|
|
$open > 0 => 'warning',
|
|
default => 'success',
|
|
},
|
|
'summary' => $summary,
|
|
'total_count' => $total,
|
|
'open_count' => $open,
|
|
'high_impact_count' => $highImpact,
|
|
'items' => [
|
|
[
|
|
'label' => __('localization.review.findings_total'),
|
|
'value' => (string) $total,
|
|
'color' => $total > 0 ? 'info' : 'gray',
|
|
],
|
|
[
|
|
'label' => __('localization.review.findings_open'),
|
|
'value' => (string) $open,
|
|
'color' => $open > 0 ? 'warning' : 'gray',
|
|
],
|
|
[
|
|
'label' => __('localization.review.findings_high_impact'),
|
|
'value' => (string) $highImpact,
|
|
'color' => $highImpact > 0 ? 'danger' : 'gray',
|
|
],
|
|
[
|
|
'label' => __('localization.review.accepted_risks'),
|
|
'value' => (string) $accepted,
|
|
'color' => $accepted > 0 ? 'info' : 'gray',
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, FindingException>
|
|
*/
|
|
private function acceptedRiskExceptionsForTenant(ManagedEnvironment $tenant): Collection
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User || ! $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant)) {
|
|
return collect();
|
|
}
|
|
|
|
return FindingException::query()
|
|
->with(['owner', 'approver', 'currentDecision'])
|
|
->where('workspace_id', (int) $tenant->workspace_id)
|
|
->where('managed_environment_id', (int) $tenant->getKey())
|
|
->current()
|
|
->orderByRaw("case when current_validity_state in ('expiring', 'expired', 'missing_support') then 0 else 1 end")
|
|
->latest('approved_at')
|
|
->latest('requested_at')
|
|
->latest('id')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, FindingException> $exceptions
|
|
* @return list<array{label:string,value:string,color:string}>
|
|
*/
|
|
private function acceptedRiskDetailRows(Collection $exceptions): array
|
|
{
|
|
if ($exceptions->isEmpty()) {
|
|
return [];
|
|
}
|
|
|
|
$owner = $exceptions
|
|
->map(static fn (FindingException $exception): ?string => $exception->owner?->name ?? $exception->approver?->name)
|
|
->filter(static fn (?string $name): bool => is_string($name) && trim($name) !== '')
|
|
->first();
|
|
$ownedException = $exceptions
|
|
->first(static fn (FindingException $exception): bool => $exception->owner?->name !== null || $exception->approver?->name !== null);
|
|
$reviewDate = $exceptions
|
|
->map(static fn (FindingException $exception): mixed => $exception->review_due_at ?? $exception->expires_at)
|
|
->first(static fn (mixed $date): bool => $date instanceof \DateTimeInterface);
|
|
$hasMissingReviewDate = $exceptions
|
|
->contains(static fn (FindingException $exception): bool => $exception->review_due_at === null && $exception->expires_at === null);
|
|
$reason = $ownedException instanceof FindingException && is_string($ownedException->request_reason) && trim($ownedException->request_reason) !== ''
|
|
? trim($ownedException->request_reason)
|
|
: $exceptions
|
|
->map(static fn (FindingException $exception): ?string => is_string($exception->request_reason) ? trim($exception->request_reason) : null)
|
|
->filter(static fn (?string $value): bool => is_string($value) && $value !== '')
|
|
->first();
|
|
|
|
$rows = [
|
|
[
|
|
'label' => __('localization.review.accepted_risk_owner'),
|
|
'value' => is_string($owner) ? $owner : __('localization.review.not_recorded'),
|
|
'color' => is_string($owner) ? 'info' : 'warning',
|
|
],
|
|
];
|
|
|
|
if ($reviewDate instanceof \DateTimeInterface) {
|
|
$rows[] = [
|
|
'label' => __('localization.review.accepted_risk_next_review'),
|
|
'value' => $reviewDate->format('Y-m-d'),
|
|
'color' => 'info',
|
|
];
|
|
}
|
|
|
|
if ($hasMissingReviewDate) {
|
|
$rows[] = [
|
|
'label' => __('localization.review.accepted_risk_next_review'),
|
|
'value' => __('localization.review.review_date_not_recorded'),
|
|
'color' => 'warning',
|
|
];
|
|
}
|
|
|
|
$rows[] = [
|
|
'label' => __('localization.review.accepted_risk_rationale'),
|
|
'value' => is_string($reason) ? Str::limit($reason, 160) : __('localization.review.not_recorded'),
|
|
'color' => is_string($reason) ? 'info' : 'warning',
|
|
];
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* @return array{summary_label:string,summary_color:string,items:list<array{label:string,value:string,color:string}>,detail_rows:list<array{label:string,value:string,color:string}>}
|
|
*/
|
|
private function acceptedRiskPanelForReview(EnvironmentReview $review, ManagedEnvironment $tenant): array
|
|
{
|
|
$package = $this->governancePackageSummaryForReview($review);
|
|
$hasFollowUp = $this->acceptedRiskFollowUpRequiredForReview($review);
|
|
$exceptions = $this->acceptedRiskExceptionsForTenant($tenant);
|
|
$acceptedEntries = collect($package['accepted_risks'] ?? [])
|
|
->filter(static fn (mixed $entry): bool => is_array($entry));
|
|
$decisionEntries = collect($package['governance_decisions'] ?? [])
|
|
->filter(static fn (mixed $entry): bool => is_array($entry));
|
|
$allEntries = $acceptedEntries->merge($decisionEntries);
|
|
|
|
$total = max($allEntries->count(), $exceptions->count());
|
|
$expiring = max(
|
|
$allEntries->where('governance_state', 'expiring_exception')->count(),
|
|
$exceptions->where('current_validity_state', FindingException::VALIDITY_EXPIRING)->count(),
|
|
);
|
|
$expired = max(
|
|
$allEntries->where('governance_state', 'expired_exception')->count(),
|
|
$exceptions->where('current_validity_state', FindingException::VALIDITY_EXPIRED)->count(),
|
|
);
|
|
$pending = max(
|
|
$allEntries->where('governance_state', 'pending_exception')->count(),
|
|
$exceptions->where('status', FindingException::STATUS_PENDING)->count(),
|
|
);
|
|
$needsReview = $allEntries
|
|
->filter(static fn (array $entry): bool => in_array(
|
|
(string) ($entry['governance_state'] ?? ''),
|
|
self::ACCEPTED_RISK_FOLLOW_UP_STATES,
|
|
true,
|
|
))
|
|
->count();
|
|
$needsReview = max($needsReview, $exceptions
|
|
->filter(static fn (FindingException $exception): bool => in_array(
|
|
(string) $exception->current_validity_state,
|
|
[
|
|
FindingException::VALIDITY_EXPIRING,
|
|
FindingException::VALIDITY_EXPIRED,
|
|
FindingException::VALIDITY_REVOKED,
|
|
FindingException::VALIDITY_MISSING_SUPPORT,
|
|
],
|
|
true,
|
|
))
|
|
->count());
|
|
|
|
return [
|
|
'summary_label' => $hasFollowUp
|
|
? __('localization.review.accepted_risk_follow_up')
|
|
: __('localization.review.accepted_risk_no_action_needed'),
|
|
'summary_color' => $hasFollowUp ? ($expired > 0 ? 'danger' : 'warning') : 'gray',
|
|
'items' => [
|
|
[
|
|
'label' => __('localization.review.accepted_risks'),
|
|
'value' => (string) $total,
|
|
'color' => $total > 0 ? 'info' : 'gray',
|
|
],
|
|
[
|
|
'label' => __('localization.review.accepted_risks_expiring_soon'),
|
|
'value' => (string) $expiring,
|
|
'color' => $expiring > 0 ? 'warning' : 'gray',
|
|
],
|
|
[
|
|
'label' => __('localization.review.accepted_risks_expired'),
|
|
'value' => (string) $expired,
|
|
'color' => $expired > 0 ? 'danger' : 'gray',
|
|
],
|
|
[
|
|
'label' => __('localization.review.accepted_risks_pending_approval'),
|
|
'value' => (string) $pending,
|
|
'color' => $pending > 0 ? 'warning' : 'gray',
|
|
],
|
|
[
|
|
'label' => __('localization.review.accepted_risks_needs_review'),
|
|
'value' => (string) $needsReview,
|
|
'color' => $needsReview > 0 ? 'warning' : 'gray',
|
|
],
|
|
],
|
|
'detail_rows' => $this->acceptedRiskDetailRows($exceptions),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{state:string,label:string,description:string} $packageAvailability
|
|
* @param array<string, mixed> $outputReadiness
|
|
* @return array{
|
|
* status_label:string,
|
|
* status_color:string,
|
|
* description:string,
|
|
* detail_rows:list<array{label:string,value:string,color:string}>,
|
|
* download_url:?string
|
|
* }
|
|
*/
|
|
private function reviewPackPanelForReview(
|
|
EnvironmentReview $review,
|
|
ManagedEnvironment $tenant,
|
|
array $packageAvailability,
|
|
?string $downloadUrl,
|
|
array $outputReadiness,
|
|
): array {
|
|
$pack = $review->currentExportReviewPack;
|
|
$snapshot = $review->evidenceSnapshot;
|
|
$evidenceBasis = $this->evidenceBasisForReview($review, $packageAvailability, $outputReadiness);
|
|
$sectionSummary = is_array($outputReadiness['section_summary'] ?? null) ? $outputReadiness['section_summary'] : [];
|
|
$packageExistsState = match (true) {
|
|
$pack instanceof ReviewPack && $pack->generated_at !== null => 'available',
|
|
$packageAvailability['state'] === 'preparing' => 'preparing',
|
|
default => 'unavailable',
|
|
};
|
|
$packageExistsLabel = match ($packageExistsState) {
|
|
'available' => __('localization.review.available'),
|
|
'preparing' => __('localization.review.preparing'),
|
|
default => __('localization.review.unavailable'),
|
|
};
|
|
$packageExistsColor = match ($packageExistsState) {
|
|
'available' => 'success',
|
|
'preparing' => 'warning',
|
|
default => 'gray',
|
|
};
|
|
$customerSharingState = (string) ($outputReadiness['customer_safe_state'] ?? 'requires_review');
|
|
|
|
return [
|
|
'status_label' => $packageAvailability['label'],
|
|
'status_color' => $this->governancePackageAvailabilityColor($tenant),
|
|
'description' => $this->reviewPackPanelDescription($packageAvailability, $outputReadiness),
|
|
'sections' => [
|
|
[
|
|
'key' => 'package_exists',
|
|
'title' => __('localization.review.package_exists'),
|
|
'label' => $packageExistsLabel,
|
|
'color' => $packageExistsColor,
|
|
'description' => $this->reviewPackPackageExistsDescription($packageExistsState),
|
|
'rows' => [
|
|
[
|
|
'label' => __('localization.review.last_generated'),
|
|
'value' => $pack instanceof ReviewPack && $pack->generated_at !== null
|
|
? $pack->generated_at->format('M j, Y H:i')
|
|
: __('localization.review.unavailable'),
|
|
'color' => 'gray',
|
|
],
|
|
[
|
|
'label' => __('localization.review.evidence_source'),
|
|
'value' => $snapshot instanceof EvidenceSnapshot && $snapshot->generated_at !== null
|
|
? $snapshot->generated_at->format('M j, Y H:i')
|
|
: __('localization.review.unavailable'),
|
|
'color' => 'gray',
|
|
],
|
|
[
|
|
'label' => __('localization.review.operation_proof'),
|
|
'value' => $pack instanceof ReviewPack && $pack->operationRun instanceof OperationRun
|
|
? OperationRunLinks::identifier($pack->operationRun)
|
|
: __('localization.review.operation_proof_unavailable'),
|
|
'color' => $pack instanceof ReviewPack && $pack->operationRun instanceof OperationRun ? 'info' : 'gray',
|
|
],
|
|
],
|
|
],
|
|
[
|
|
'key' => 'internal_export',
|
|
'title' => __('localization.review.internal_export'),
|
|
'label' => $downloadUrl !== null
|
|
? __('localization.review.export_ready')
|
|
: __('localization.review.export_not_ready'),
|
|
'color' => $downloadUrl !== null
|
|
? 'success'
|
|
: ($packageAvailability['state'] === 'preparing' ? 'warning' : 'gray'),
|
|
'description' => $this->reviewPackInternalExportDescription($packageAvailability, $downloadUrl),
|
|
'rows' => [
|
|
[
|
|
'label' => __('localization.review.export_availability'),
|
|
'value' => $downloadUrl !== null
|
|
? __('localization.review.export_ready')
|
|
: __('localization.review.export_not_ready'),
|
|
'color' => $downloadUrl !== null ? 'success' : 'gray',
|
|
],
|
|
[
|
|
'label' => __('localization.review.evidence_basis_state'),
|
|
'value' => $evidenceBasis['label'],
|
|
'color' => $evidenceBasis['color'],
|
|
],
|
|
[
|
|
'label' => __('localization.review.section_completeness'),
|
|
'value' => $this->sectionCompletenessLabel($sectionSummary),
|
|
'color' => ((int) ($sectionSummary['required_limited'] ?? 0)) > 0 ? 'warning' : 'success',
|
|
],
|
|
],
|
|
],
|
|
[
|
|
'key' => 'customer_sharing',
|
|
'title' => __('localization.review.customer_sharing'),
|
|
'label' => $this->workspaceBoundaryLabel($customerSharingState),
|
|
'color' => $this->workspaceBoundaryColor($customerSharingState),
|
|
'description' => $this->reviewPackCustomerSharingDescription($outputReadiness),
|
|
'rows' => [
|
|
[
|
|
'label' => __('localization.review.sharing_boundary'),
|
|
'value' => $this->workspaceBoundaryLabel($customerSharingState),
|
|
'color' => $this->workspaceBoundaryColor($customerSharingState),
|
|
],
|
|
[
|
|
'label' => __('localization.review.pii_state'),
|
|
'value' => (bool) ($outputReadiness['contains_pii'] ?? false)
|
|
? __('localization.review.contains_pii')
|
|
: __('localization.review.pii_excluded'),
|
|
'color' => (bool) ($outputReadiness['contains_pii'] ?? false) ? 'warning' : 'success',
|
|
],
|
|
[
|
|
'label' => __('localization.review.protected_values'),
|
|
'value' => (bool) ($outputReadiness['protected_values_hidden'] ?? true)
|
|
? __('localization.review.protected_values_hidden')
|
|
: __('localization.review.unavailable'),
|
|
'color' => (bool) ($outputReadiness['protected_values_hidden'] ?? true) ? 'success' : 'warning',
|
|
],
|
|
[
|
|
'label' => __('localization.review.disclosure'),
|
|
'value' => (bool) ($outputReadiness['disclosure_present'] ?? false)
|
|
? __('localization.review.disclosure_present')
|
|
: __('localization.review.unavailable'),
|
|
'color' => (bool) ($outputReadiness['disclosure_present'] ?? false) ? 'success' : 'warning',
|
|
],
|
|
],
|
|
],
|
|
],
|
|
'download_url' => $downloadUrl,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $decision
|
|
* @return array{entries:list<array<string,string>>,empty_state:string}
|
|
*/
|
|
private function customerSafeFollowUpsForReview(array $decision): array
|
|
{
|
|
$entries = collect($decision['entries'] ?? [])
|
|
->filter(static fn (mixed $entry): bool => is_array($entry))
|
|
->map(static fn (array $entry): array => [
|
|
'title' => (string) ($entry['title'] ?? __('localization.review.follow_up')),
|
|
'priority' => (string) ($decision['label'] ?? __('localization.review.follow_up')),
|
|
'proof' => __('localization.review.decision_trail'),
|
|
'summary' => (string) ($entry['summary'] ?? __('localization.review.decision_entry_customer_safe_summary')),
|
|
'next_action' => (string) ($entry['next_action'] ?? __('localization.review.decision_summary_requires_awareness_next_action')),
|
|
])
|
|
->take(3)
|
|
->values()
|
|
->all();
|
|
|
|
return [
|
|
'entries' => $entries,
|
|
'empty_state' => __('localization.review.customer_safe_follow_ups_empty'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{label:string,summary:string}
|
|
*/
|
|
private function diagnosticsDisclosureForReview(): array
|
|
{
|
|
return [
|
|
'label' => __('localization.review.diagnostics'),
|
|
'summary' => __('localization.review.diagnostics_customer_workspace_default_hidden'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array{label:string,value:string,color:string}>
|
|
*/
|
|
private function disclosureRuleRows(): array
|
|
{
|
|
return [
|
|
[
|
|
'label' => __('localization.review.disclosure_decision'),
|
|
'value' => __('localization.review.disclosure_visible'),
|
|
'color' => 'info',
|
|
],
|
|
[
|
|
'label' => __('localization.review.disclosure_evidence'),
|
|
'value' => __('localization.review.disclosure_visible'),
|
|
'color' => 'info',
|
|
],
|
|
[
|
|
'label' => __('localization.review.disclosure_diagnostics'),
|
|
'value' => __('localization.review.disclosure_collapsed'),
|
|
'color' => 'gray',
|
|
],
|
|
[
|
|
'label' => __('localization.review.disclosure_raw_support'),
|
|
'value' => __('localization.review.disclosure_hidden'),
|
|
'color' => 'gray',
|
|
],
|
|
];
|
|
}
|
|
|
|
private function authorizePageAccess(): void
|
|
{
|
|
$user = auth()->user();
|
|
$workspace = $this->workspace();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
$service = app(EnvironmentReviewRegisterService::class);
|
|
|
|
if (! $service->canAccessWorkspace($user, $workspace)) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
if ($this->authorizedTenants() === []) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
}
|
|
|
|
private function auditWorkspaceOpen(): void
|
|
{
|
|
$user = auth()->user();
|
|
$workspace = $this->workspace();
|
|
|
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
return;
|
|
}
|
|
|
|
app(WorkspaceAuditLogger::class)->log(
|
|
workspace: $workspace,
|
|
action: AuditActionId::CustomerReviewWorkspaceOpened,
|
|
context: [
|
|
'metadata' => [
|
|
'source_surface' => self::SOURCE_SURFACE,
|
|
'tenant_filter_id' => $this->currentTenantFilterId(),
|
|
'entitled_tenant_count' => count($this->authorizedTenants()),
|
|
'interpretation_version' => $this->currentTenantFilterInterpretationVersion(),
|
|
'interpretation_versions' => $this->visibleInterpretationVersions(),
|
|
],
|
|
],
|
|
actor: $user,
|
|
resourceType: 'customer_review_workspace',
|
|
resourceId: (string) $workspace->getKey(),
|
|
targetLabel: __('localization.review.customer_review_workspace'),
|
|
);
|
|
}
|
|
|
|
private function workspaceQuery(): Builder
|
|
{
|
|
$user = auth()->user();
|
|
$workspace = $this->workspace();
|
|
|
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
return ManagedEnvironment::query()->whereRaw('1 = 0');
|
|
}
|
|
|
|
return app(EnvironmentReviewRegisterService::class)->customerWorkspaceTenantQuery($user, $workspace);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function tenantFilterOptions(): array
|
|
{
|
|
return collect($this->authorizedTenants())
|
|
->mapWithKeys(static fn (ManagedEnvironment $tenant): array => [
|
|
(string) $tenant->getKey() => $tenant->name,
|
|
])
|
|
->all();
|
|
}
|
|
|
|
private function defaultTenantFilter(): ?string
|
|
{
|
|
return null;
|
|
}
|
|
|
|
private function applyRequestedTenantPrefilter(): void
|
|
{
|
|
$workspace = $this->workspace();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return;
|
|
}
|
|
|
|
$filter = WorkspaceHubEnvironmentFilter::fromRequest(request(), $workspace);
|
|
|
|
if (! $filter instanceof WorkspaceHubEnvironmentFilter) {
|
|
return;
|
|
}
|
|
|
|
$environmentId = $filter->environmentId();
|
|
|
|
foreach ($this->authorizedTenants() as $tenant) {
|
|
if ((int) $tenant->getKey() === $environmentId) {
|
|
$this->tableFilters['managed_environment_id']['value'] = (string) $environmentId;
|
|
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $environmentId;
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
private function hasActiveFilters(): bool
|
|
{
|
|
return $this->currentTenantFilterId() !== null;
|
|
}
|
|
|
|
private function clearWorkspaceFilters(): void
|
|
{
|
|
$hadEnvironmentFilter = $this->currentTenantFilterId() !== null;
|
|
|
|
$this->removeTableFilters();
|
|
$this->clearWorkspaceHubEnvironmentFilterState(request());
|
|
|
|
if ($hadEnvironmentFilter) {
|
|
$this->redirectToCleanWorkspaceHubUrl(static::getUrl(panel: 'admin'), request());
|
|
}
|
|
}
|
|
|
|
private function workspaceEmptyStateHeading(): string
|
|
{
|
|
return $this->filteredViewHasNoReleasedReviewsButWorkspaceHasMatches()
|
|
? __('localization.review.filtered_no_released_customer_reviews')
|
|
: __('localization.review.no_released_customer_reviews');
|
|
}
|
|
|
|
private function workspaceEmptyStateDescription(): string
|
|
{
|
|
if ($this->filteredViewHasNoReleasedReviewsButWorkspaceHasMatches()) {
|
|
return __('localization.review.filtered_no_released_customer_reviews_description');
|
|
}
|
|
|
|
return __('localization.review.no_released_customer_reviews_description');
|
|
}
|
|
|
|
private function filteredViewHasNoReleasedReviewsButWorkspaceHasMatches(): bool
|
|
{
|
|
$user = auth()->user();
|
|
$workspace = $this->workspace();
|
|
$tenant = $this->filteredTenant();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
return false;
|
|
}
|
|
|
|
if ($this->latestPublishedReview($tenant) instanceof EnvironmentReview) {
|
|
return false;
|
|
}
|
|
|
|
return app(EnvironmentReviewRegisterService::class)
|
|
->customerWorkspaceLifecycleReviewQuery($user, $workspace)
|
|
->exists();
|
|
}
|
|
|
|
private function currentTenantFilterId(): ?int
|
|
{
|
|
$tenantFilter = data_get($this->tableFilters, 'managed_environment_id.value');
|
|
|
|
if (! is_numeric($tenantFilter)) {
|
|
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'managed_environment_id.value');
|
|
}
|
|
|
|
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
|
}
|
|
|
|
private function filteredTenant(): ?ManagedEnvironment
|
|
{
|
|
$tenantId = $this->currentTenantFilterId();
|
|
|
|
if (! is_int($tenantId)) {
|
|
return null;
|
|
}
|
|
|
|
foreach ($this->authorizedTenants() as $tenant) {
|
|
if ((int) $tenant->getKey() === $tenantId) {
|
|
return $tenant;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function workspace(): ?Workspace
|
|
{
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
return is_numeric($workspaceId)
|
|
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
|
: null;
|
|
}
|
|
|
|
private function latestReleasedTenant(): ?ManagedEnvironment
|
|
{
|
|
$user = auth()->user();
|
|
$workspace = $this->workspace();
|
|
|
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
return null;
|
|
}
|
|
|
|
$query = app(EnvironmentReviewRegisterService::class)->customerWorkspaceLifecycleReviewQuery($user, $workspace);
|
|
$tenantFilterId = $this->currentTenantFilterId();
|
|
|
|
if ($tenantFilterId !== null) {
|
|
$query->where('managed_environment_id', $tenantFilterId);
|
|
}
|
|
|
|
$review = $query->first();
|
|
|
|
if (! $review instanceof EnvironmentReview || ! $review->tenant instanceof ManagedEnvironment) {
|
|
return null;
|
|
}
|
|
|
|
$tenant = $review->tenant;
|
|
$tenant->setRelation('environmentReviews', $review->newCollection([$review]));
|
|
|
|
return $tenant;
|
|
}
|
|
|
|
private function latestPublishedReview(ManagedEnvironment $tenant): ?EnvironmentReview
|
|
{
|
|
if ($tenant->relationLoaded('environmentReviews')) {
|
|
$review = $tenant->environmentReviews->first();
|
|
|
|
return $review instanceof EnvironmentReview ? $review : null;
|
|
}
|
|
|
|
return $tenant->environmentReviews()
|
|
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack', 'supersededByReview'])
|
|
->where(function (Builder $query): void {
|
|
$query->published()
|
|
->orWhere(function (Builder $query): void {
|
|
$query
|
|
->where('status', EnvironmentReviewStatus::Superseded->value)
|
|
->whereNotNull('superseded_by_review_id');
|
|
});
|
|
})
|
|
->orderByDesc('published_at')
|
|
->orderByDesc('generated_at')
|
|
->orderByDesc('id')
|
|
->first();
|
|
}
|
|
|
|
private function workspaceLifecycleReview(): ?EnvironmentReview
|
|
{
|
|
$tenant = $this->latestReleasedTenant();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return null;
|
|
}
|
|
|
|
return $this->latestPublishedReview($tenant);
|
|
}
|
|
|
|
private function latestReviewUrl(ManagedEnvironment $tenant): ?string
|
|
{
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return null;
|
|
}
|
|
|
|
$query = array_filter(
|
|
array_replace(
|
|
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
|
|
[
|
|
'source_surface' => self::SOURCE_SURFACE,
|
|
'tenant_filter_id' => $this->currentTenantFilterId(),
|
|
],
|
|
$this->navigationContext()?->toQuery() ?? [],
|
|
),
|
|
static fn (mixed $value): bool => $value !== null && $value !== '',
|
|
);
|
|
|
|
return $this->appendQuery(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review], $tenant), $query);
|
|
}
|
|
|
|
private function successorReviewUrlForReview(EnvironmentReview $review, ManagedEnvironment $tenant): ?string
|
|
{
|
|
if (! is_numeric($review->superseded_by_review_id)) {
|
|
return null;
|
|
}
|
|
|
|
$successorReviewId = (int) $review->superseded_by_review_id;
|
|
|
|
if (! EnvironmentReview::query()
|
|
->whereKey($successorReviewId)
|
|
->where('workspace_id', (int) $review->workspace_id)
|
|
->where('managed_environment_id', (int) $review->managed_environment_id)
|
|
->exists()) {
|
|
return null;
|
|
}
|
|
|
|
return $this->appendQuery(
|
|
EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $successorReviewId], $tenant),
|
|
array_filter([
|
|
'source_surface' => self::SOURCE_SURFACE,
|
|
'tenant_filter_id' => $this->currentTenantFilterId(),
|
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
);
|
|
}
|
|
|
|
private function operationUrlForReview(EnvironmentReview $review): ?string
|
|
{
|
|
$operationRun = $review->currentExportReviewPack?->operationRun ?? $review->operationRun;
|
|
|
|
if (! $operationRun instanceof OperationRun) {
|
|
return null;
|
|
}
|
|
|
|
return OperationRunLinks::tenantlessView((int) $operationRun->getKey());
|
|
}
|
|
|
|
private function canManageReview(EnvironmentReview $review): bool
|
|
{
|
|
$user = auth()->user();
|
|
$tenant = $review->tenant;
|
|
|
|
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
|
return false;
|
|
}
|
|
|
|
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::ENVIRONMENT_REVIEW_MANAGE);
|
|
}
|
|
|
|
private function reviewPackDownloadUrl(EnvironmentReview $review, ManagedEnvironment $tenant): ?string
|
|
{
|
|
$pack = $review->currentExportReviewPack;
|
|
$user = auth()->user();
|
|
|
|
if (! $pack instanceof ReviewPack || ! $user instanceof User) {
|
|
return null;
|
|
}
|
|
|
|
if ($this->governancePackageAvailability($tenant)['state'] !== 'available') {
|
|
return null;
|
|
}
|
|
|
|
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
|
return null;
|
|
}
|
|
|
|
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
|
return null;
|
|
}
|
|
|
|
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
|
return null;
|
|
}
|
|
|
|
if (! filled($pack->file_path) || ! filled($pack->file_disk)) {
|
|
return null;
|
|
}
|
|
|
|
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
|
'source_surface' => self::SOURCE_SURFACE,
|
|
'review_id' => (int) $review->getKey(),
|
|
'tenant_filter_id' => (string) ($this->currentTenantFilterId() ?? $tenant->getKey()),
|
|
'interpretation_version' => $review->controlInterpretationVersion(),
|
|
]);
|
|
}
|
|
|
|
private function latestPublishedAt(ManagedEnvironment $tenant): ?\Illuminate\Support\Carbon
|
|
{
|
|
return $this->latestPublishedReview($tenant)?->published_at;
|
|
}
|
|
|
|
private function reviewTruth(ManagedEnvironment $tenant): ?ArtifactTruthEnvelope
|
|
{
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
return $review instanceof EnvironmentReview
|
|
? app(ArtifactTruthPresenter::class)->forEnvironmentReview($review)
|
|
: null;
|
|
}
|
|
|
|
private function reviewOutcome(ManagedEnvironment $tenant): ?CompressedGovernanceOutcome
|
|
{
|
|
$presenter = app(ArtifactTruthPresenter::class);
|
|
$review = $this->latestPublishedReview($tenant);
|
|
$truth = $this->reviewTruth($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview || ! $truth instanceof ArtifactTruthEnvelope) {
|
|
return null;
|
|
}
|
|
|
|
return $presenter->compressedOutcomeFor($review, SurfaceCompressionContext::reviewRegister())
|
|
?? $presenter->compressedOutcomeFromEnvelope($truth, SurfaceCompressionContext::reviewRegister());
|
|
}
|
|
|
|
private function latestReviewStateLabel(ManagedEnvironment $tenant): string
|
|
{
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return __('localization.review.no_published_review');
|
|
}
|
|
|
|
return match ($this->workspaceCustomerOutputState($tenant)) {
|
|
'ready' => __('localization.review.ready'),
|
|
'not_ready' => __('localization.review.not_ready'),
|
|
default => __('localization.review.needs_review'),
|
|
};
|
|
}
|
|
|
|
private function latestReviewStateColor(ManagedEnvironment $tenant): string
|
|
{
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return 'gray';
|
|
}
|
|
|
|
return match ($this->workspaceCustomerOutputState($tenant)) {
|
|
'ready' => 'success',
|
|
'not_ready' => in_array($this->governancePackageAvailability($tenant)['state'], ['expired', 'unavailable'], true)
|
|
? 'danger'
|
|
: 'gray',
|
|
default => 'warning',
|
|
};
|
|
}
|
|
|
|
private function latestReviewStateIcon(ManagedEnvironment $tenant): ?string
|
|
{
|
|
return $this->reviewOutcome($tenant)?->primaryBadge->icon;
|
|
}
|
|
|
|
private function latestReviewStateIconColor(ManagedEnvironment $tenant): ?string
|
|
{
|
|
return $this->reviewOutcome($tenant)?->primaryBadge->iconColor;
|
|
}
|
|
|
|
private function reviewOutcomeDescription(ManagedEnvironment $tenant): ?string
|
|
{
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return __('localization.review.no_published_review_available');
|
|
}
|
|
|
|
$primaryReason = $this->reviewOutcome($tenant)?->primaryReason;
|
|
$summary = is_array($review->summary) ? $review->summary : [];
|
|
$findingOutcomes = $summary['finding_outcomes'] ?? null;
|
|
|
|
if (! is_array($findingOutcomes)) {
|
|
return $primaryReason;
|
|
}
|
|
|
|
$findingOutcomeSummary = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
|
|
|
|
if ($findingOutcomeSummary === null) {
|
|
return $primaryReason;
|
|
}
|
|
|
|
return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
|
|
}
|
|
|
|
private function controlReadinessLabel(ManagedEnvironment $tenant): string
|
|
{
|
|
$control = $this->primaryControlSummary($tenant);
|
|
|
|
if ($control === null) {
|
|
return __('localization.review.control_readiness_unmapped');
|
|
}
|
|
|
|
$label = $control['readiness_label'] ?? null;
|
|
|
|
return is_string($label) && trim($label) !== ''
|
|
? $label
|
|
: ComplianceEvidenceMappingV1::readinessLabel((string) ($control['readiness_bucket'] ?? 'review_recommended'));
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function governancePackageSummary(ManagedEnvironment $tenant): array
|
|
{
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return [];
|
|
}
|
|
|
|
return $this->governancePackageSummaryForReview($review);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function governancePackageSummaryForReview(EnvironmentReview $review): array
|
|
{
|
|
$summary = is_array($review->summary) ? $review->summary : [];
|
|
$package = is_array($summary['governance_package'] ?? null) ? $summary['governance_package'] : [];
|
|
|
|
return $package;
|
|
}
|
|
|
|
/**
|
|
* @return array{state:string,label:string,description:string}
|
|
*/
|
|
private function governancePackageAvailability(ManagedEnvironment $tenant): array
|
|
{
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return [
|
|
'state' => 'unavailable',
|
|
'label' => __('localization.review.governance_package_unavailable'),
|
|
'description' => __('localization.review.no_published_review_available'),
|
|
];
|
|
}
|
|
|
|
$pack = $review->currentExportReviewPack;
|
|
$user = auth()->user();
|
|
|
|
if (! $pack instanceof ReviewPack) {
|
|
return [
|
|
'state' => 'not_available',
|
|
'label' => __('localization.review.review_pack_not_available_yet'),
|
|
'description' => __('localization.review.review_pack_not_available_yet_description'),
|
|
];
|
|
}
|
|
|
|
if (! $user instanceof User || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
|
return [
|
|
'state' => 'unavailable',
|
|
'label' => __('localization.review.unavailable'),
|
|
'description' => __('localization.review.review_pack_unavailable_customer_description'),
|
|
];
|
|
}
|
|
|
|
if ($pack->status === ReviewPackStatus::Expired->value || ($pack->expires_at !== null && $pack->expires_at->isPast())) {
|
|
return [
|
|
'state' => 'expired',
|
|
'label' => __('localization.review.governance_package_expired'),
|
|
'description' => __('localization.review.governance_package_expired_description'),
|
|
];
|
|
}
|
|
|
|
if (in_array($pack->status, [ReviewPackStatus::Queued->value, ReviewPackStatus::Generating->value], true)) {
|
|
return [
|
|
'state' => 'preparing',
|
|
'label' => __('localization.review.review_pack_preparing'),
|
|
'description' => __('localization.review.review_pack_preparing_description'),
|
|
];
|
|
}
|
|
|
|
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
|
return [
|
|
'state' => 'unavailable',
|
|
'label' => __('localization.review.unavailable'),
|
|
'description' => __('localization.review.review_pack_unavailable_customer_description'),
|
|
];
|
|
}
|
|
|
|
if (! filled($pack->file_path) || ! filled($pack->file_disk)) {
|
|
return [
|
|
'state' => 'not_available',
|
|
'label' => __('localization.review.review_pack_not_available_yet'),
|
|
'description' => __('localization.review.review_pack_not_available_yet_description'),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'state' => 'available',
|
|
'label' => __('localization.review.available'),
|
|
'description' => __('localization.review.review_pack_available_customer_description'),
|
|
];
|
|
}
|
|
|
|
private function governancePackageAvailabilityLabel(ManagedEnvironment $tenant): string
|
|
{
|
|
return match ($this->governancePackageAvailability($tenant)['state']) {
|
|
'available' => __('localization.review.available'),
|
|
'not_available' => __('localization.review.review_pack_not_available_yet'),
|
|
'preparing' => __('localization.review.review_pack_preparing'),
|
|
'expired' => __('localization.review.expired'),
|
|
default => __('localization.review.unavailable'),
|
|
};
|
|
}
|
|
|
|
private function governancePackageAvailabilityColor(ManagedEnvironment $tenant): string
|
|
{
|
|
return match ($this->governancePackageAvailability($tenant)['state']) {
|
|
'available' => 'success',
|
|
'preparing' => 'warning',
|
|
'expired', 'unavailable' => 'danger',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
|
|
private function governancePackageTeaser(ManagedEnvironment $tenant): string
|
|
{
|
|
$package = $this->governancePackageSummary($tenant);
|
|
|
|
$executiveSummary = $package['executive_summary'] ?? null;
|
|
|
|
if (is_string($executiveSummary) && trim($executiveSummary) !== '') {
|
|
return $executiveSummary;
|
|
}
|
|
|
|
return $this->governancePackageAvailability($tenant)['description'];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function decisionSummaryForReview(EnvironmentReview $review): array
|
|
{
|
|
$package = $this->governancePackageSummaryForReview($review);
|
|
$decisionSummary = is_array($package['decision_summary'] ?? null) ? $package['decision_summary'] : [];
|
|
|
|
if ($decisionSummary === []) {
|
|
return [
|
|
'status' => 'unavailable',
|
|
'label' => __('localization.review.decision_evidence_unavailable'),
|
|
'color' => 'warning',
|
|
'total_count' => 0,
|
|
'summary' => __('localization.review.decision_summary_unavailable_description'),
|
|
'next_action' => __('localization.review.decision_summary_unavailable_next_action'),
|
|
'entries' => [],
|
|
];
|
|
}
|
|
|
|
$status = (string) ($decisionSummary['status'] ?? 'unavailable');
|
|
|
|
if (! in_array($status, ['none', 'requires_awareness', 'unavailable', 'incomplete'], true)) {
|
|
$status = 'unavailable';
|
|
}
|
|
|
|
$entries = collect($decisionSummary['entries'] ?? [])
|
|
->filter(static fn (mixed $entry): bool => is_array($entry))
|
|
->map(fn (array $entry): array => [
|
|
'title' => $this->customerSafeText($entry['title'] ?? null, __('localization.review.governance_decisions')),
|
|
'summary' => $this->customerSafeText($entry['summary'] ?? null, __('localization.review.decision_entry_customer_safe_summary')),
|
|
'next_action' => $this->customerSafeText($entry['next_action'] ?? null, __('localization.review.decision_summary_requires_awareness_next_action')),
|
|
])
|
|
->take(3)
|
|
->values()
|
|
->all();
|
|
|
|
return [
|
|
'status' => $status,
|
|
'label' => $this->decisionSummaryLabel($status),
|
|
'color' => $this->decisionSummaryColor($status),
|
|
'total_count' => (int) ($decisionSummary['total_count'] ?? count($entries)),
|
|
'summary' => $this->customerSafeText(
|
|
$decisionSummary['summary'] ?? null,
|
|
$this->decisionSummaryFallbackText($status),
|
|
),
|
|
'next_action' => $this->customerSafeText(
|
|
$decisionSummary['next_action'] ?? null,
|
|
$this->decisionSummaryFallbackNextAction($status),
|
|
),
|
|
'entries' => $entries,
|
|
];
|
|
}
|
|
|
|
private function decisionSummaryLabel(string $status): string
|
|
{
|
|
return match ($status) {
|
|
'requires_awareness' => __('localization.review.governance_decisions_requiring_awareness'),
|
|
'none' => __('localization.review.no_decisions_require_awareness'),
|
|
'incomplete' => __('localization.review.decision_evidence_incomplete'),
|
|
default => __('localization.review.decision_evidence_unavailable'),
|
|
};
|
|
}
|
|
|
|
private function decisionSummaryColor(string $status): string
|
|
{
|
|
return match ($status) {
|
|
'requires_awareness' => 'warning',
|
|
'none' => 'success',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
|
|
private function decisionSummaryFallbackText(string $status): string
|
|
{
|
|
return match ($status) {
|
|
'requires_awareness' => __('localization.review.decision_summary_requires_awareness_description'),
|
|
'none' => __('localization.review.no_decisions_require_awareness_description'),
|
|
'incomplete' => __('localization.review.decision_summary_incomplete_description'),
|
|
default => __('localization.review.decision_summary_unavailable_description'),
|
|
};
|
|
}
|
|
|
|
private function decisionSummaryFallbackNextAction(string $status): string
|
|
{
|
|
return match ($status) {
|
|
'requires_awareness' => __('localization.review.decision_summary_requires_awareness_next_action'),
|
|
'none' => __('localization.review.no_decisions_require_awareness_next_action'),
|
|
'incomplete' => __('localization.review.decision_summary_incomplete_next_action'),
|
|
default => __('localization.review.decision_summary_unavailable_next_action'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array{count:int,entries:list<array<string, string>>,empty_state:string}
|
|
*/
|
|
private function acceptedRisksForReview(EnvironmentReview $review): array
|
|
{
|
|
$package = $this->governancePackageSummaryForReview($review);
|
|
$entries = collect($package['accepted_risks'] ?? [])
|
|
->filter(static fn (mixed $entry): bool => is_array($entry))
|
|
->map(fn (array $entry): array => [
|
|
'title' => $this->customerSafeText($entry['title'] ?? null, __('localization.review.accepted_risk_state_on_record')),
|
|
'state_label' => $this->acceptedRiskStateLabel(is_string($entry['governance_state'] ?? null) ? $entry['governance_state'] : null),
|
|
'summary' => $this->customerSafeText(
|
|
$entry['customer_summary'] ?? null,
|
|
__('localization.review.accepted_risk_customer_safe_summary'),
|
|
),
|
|
])
|
|
->values();
|
|
|
|
return [
|
|
'count' => $entries->count(),
|
|
'entries' => $entries->take(3)->all(),
|
|
'empty_state' => __('localization.review.no_accepted_risks_recorded'),
|
|
];
|
|
}
|
|
|
|
private function acceptedRiskStateLabel(?string $state): string
|
|
{
|
|
return match ($state) {
|
|
'valid_exception' => __('localization.review.accepted_risk_state_current'),
|
|
'expiring_exception' => __('localization.review.accepted_risk_state_review_due'),
|
|
default => __('localization.review.accepted_risk_state_on_record'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array{state:string,label:string,description:string} $packageAvailability
|
|
* @param array<string, mixed> $outputReadiness
|
|
* @return array<string, string>
|
|
*/
|
|
private function evidenceBasisForReview(EnvironmentReview $review, array $packageAvailability, array $outputReadiness): array
|
|
{
|
|
$package = $this->governancePackageSummaryForReview($review);
|
|
$decision = $this->decisionSummaryForReview($review);
|
|
$pack = $review->currentExportReviewPack;
|
|
$evidenceState = (string) ($outputReadiness['evidence_completeness_state'] ?? '');
|
|
|
|
$state = match (true) {
|
|
$package === [] => 'unavailable',
|
|
! $pack instanceof ReviewPack => 'not_generated',
|
|
$evidenceState === EnvironmentReviewCompletenessState::Missing->value => 'missing',
|
|
$evidenceState === EnvironmentReviewCompletenessState::Stale->value => 'stale',
|
|
$evidenceState === EnvironmentReviewCompletenessState::Partial->value || $decision['status'] === 'incomplete' => 'incomplete',
|
|
$decision['status'] === 'unavailable' => 'unavailable',
|
|
$decision['status'] === 'none' => 'no_awareness_required',
|
|
default => 'complete',
|
|
};
|
|
|
|
return [
|
|
'state' => $state,
|
|
'label' => $this->evidenceBasisLabel($state),
|
|
'summary' => $this->evidenceBasisSummary($state),
|
|
'color' => $this->evidenceBasisColor($state),
|
|
];
|
|
}
|
|
|
|
private function evidenceBasisLabel(string $state): string
|
|
{
|
|
return match ($state) {
|
|
'complete' => __('localization.review.evidence_basis_complete'),
|
|
'no_awareness_required' => __('localization.review.evidence_basis_no_awareness_required'),
|
|
'missing' => __('localization.review.evidence_basis_missing'),
|
|
'stale' => __('localization.review.evidence_basis_stale'),
|
|
'incomplete' => __('localization.review.evidence_basis_incomplete'),
|
|
'not_generated' => __('localization.review.evidence_basis_not_generated'),
|
|
default => __('localization.review.evidence_basis_unavailable'),
|
|
};
|
|
}
|
|
|
|
private function evidenceBasisSummary(string $state): string
|
|
{
|
|
return match ($state) {
|
|
'complete' => __('localization.review.evidence_basis_complete_description'),
|
|
'no_awareness_required' => __('localization.review.evidence_basis_no_awareness_required_description'),
|
|
'missing' => __('localization.review.evidence_basis_missing_description'),
|
|
'stale' => __('localization.review.evidence_basis_stale_description'),
|
|
'incomplete' => __('localization.review.evidence_basis_incomplete_description'),
|
|
'not_generated' => __('localization.review.evidence_basis_not_generated_description'),
|
|
default => __('localization.review.evidence_basis_unavailable_description'),
|
|
};
|
|
}
|
|
|
|
private function evidenceBasisColor(string $state): string
|
|
{
|
|
return match ($state) {
|
|
'complete', 'no_awareness_required' => 'success',
|
|
'missing', 'stale', 'incomplete' => 'warning',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
|
|
private function evidenceSnapshotUrlForReview(EnvironmentReview $review, ManagedEnvironment $tenant): ?string
|
|
{
|
|
$snapshot = $review->evidenceSnapshot;
|
|
$user = auth()->user();
|
|
|
|
if (! $snapshot instanceof EvidenceSnapshot || ! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
|
return null;
|
|
}
|
|
|
|
return EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'admin');
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function reviewPackOutputReadinessForReview(EnvironmentReview $review): array
|
|
{
|
|
return ReviewPackOutputResolutionGuidance::readinessForReview($review);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function reviewOutputGuidanceForReview(
|
|
EnvironmentReview $review,
|
|
?string $downloadUrl,
|
|
?string $reviewUrl,
|
|
?string $evidenceUrl,
|
|
): array {
|
|
$operationUrl = null;
|
|
$operationRun = $review->currentExportReviewPack?->operationRun ?? $review->operationRun;
|
|
|
|
if ($operationRun instanceof OperationRun) {
|
|
$operationUrl = OperationRunLinks::tenantlessView((int) $operationRun->getKey());
|
|
}
|
|
|
|
return ReviewPackOutputResolutionGuidance::fromReadiness(
|
|
$this->reviewPackOutputReadinessForReview($review),
|
|
[
|
|
'download' => $downloadUrl,
|
|
'review' => $reviewUrl,
|
|
'evidence' => $evidenceUrl,
|
|
'operation' => $operationUrl,
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $outputGuidance
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function reviewOutputResolutionCaseForReview(EnvironmentReview $review, array $outputGuidance): array
|
|
{
|
|
$tenant = $review->tenant;
|
|
|
|
return ReviewPackOutputResolutionAdapter::fromGuidance(
|
|
review: $review,
|
|
guidance: $outputGuidance,
|
|
sourceSurface: self::SOURCE_SURFACE,
|
|
context: [
|
|
'urls' => [
|
|
'review' => $tenant instanceof ManagedEnvironment ? $this->latestReviewUrl($tenant) : null,
|
|
'evidence' => $tenant instanceof ManagedEnvironment
|
|
? $this->evidenceSnapshotUrlForReview($review, $tenant)
|
|
: null,
|
|
'operation' => $this->operationUrlForReview($review),
|
|
'download' => $tenant instanceof ManagedEnvironment
|
|
? $this->reviewPackDownloadUrl($review, $tenant)
|
|
: null,
|
|
'successor_review' => $tenant instanceof ManagedEnvironment
|
|
? $this->successorReviewUrlForReview($review, $tenant)
|
|
: null,
|
|
],
|
|
'execution' => [
|
|
'can_manage_review' => $this->canManageReview($review),
|
|
'successor_review_status' => $this->successorReviewStatusForReview($review),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
private function reviewPackHasReadyExport(?ReviewPack $pack): bool
|
|
{
|
|
if (! $pack instanceof ReviewPack) {
|
|
return false;
|
|
}
|
|
|
|
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
|
return false;
|
|
}
|
|
|
|
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
|
return false;
|
|
}
|
|
|
|
return filled($pack->file_path) && filled($pack->file_disk);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $outputReadiness
|
|
*/
|
|
private function effectiveWorkspaceReadinessState(array $outputReadiness, bool $hasFindingFollowUp, bool $hasAcceptedRiskFollowUp): string
|
|
{
|
|
$state = (string) ($outputReadiness['readiness_state'] ?? ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY);
|
|
|
|
if ($state === ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY && ($hasFindingFollowUp || $hasAcceptedRiskFollowUp)) {
|
|
return ReviewPackOutputReadiness::STATE_PUBLISHED_WITH_LIMITATIONS;
|
|
}
|
|
|
|
return $state;
|
|
}
|
|
|
|
private function workspaceReadinessLabel(string $state): string
|
|
{
|
|
return match ($state) {
|
|
ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => __('localization.review.customer_safe_review_pack_ready'),
|
|
ReviewPackOutputReadiness::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE => __('localization.review.internal_review_package_available'),
|
|
ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => __('localization.review.export_not_ready'),
|
|
default => __('localization.review.published_with_limitations'),
|
|
};
|
|
}
|
|
|
|
private function workspaceReadinessColor(string $state): string
|
|
{
|
|
return match ($state) {
|
|
ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => 'success',
|
|
ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => 'gray',
|
|
default => 'warning',
|
|
};
|
|
}
|
|
|
|
private function workspaceBoundaryLabel(string $state): string
|
|
{
|
|
return match ($state) {
|
|
'customer_safe_ready' => __('localization.review.customer_safe'),
|
|
'internal_only' => __('localization.review.internal_only'),
|
|
'not_ready' => __('localization.review.not_ready'),
|
|
default => __('localization.review.requires_review'),
|
|
};
|
|
}
|
|
|
|
private function workspaceBoundaryColor(string $state): string
|
|
{
|
|
return match ($state) {
|
|
'customer_safe_ready' => 'success',
|
|
'internal_only', 'requires_review' => 'warning',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $outputReadiness
|
|
* @param array{state:string,label:string,description:string} $packageAvailability
|
|
* @param array{summary:string} $findingPanel
|
|
*/
|
|
private function workspaceReadinessReason(
|
|
string $reasonCode,
|
|
array $outputReadiness,
|
|
array $findingPanel,
|
|
array $packageAvailability,
|
|
): string {
|
|
return match ($reasonCode) {
|
|
'findings_follow_up_required' => __('localization.review.findings_follow_up_required_reason', [
|
|
'summary' => $findingPanel['summary'],
|
|
]),
|
|
'accepted_risk_follow_up_required' => __('localization.review.accepted_risk_follow_up_required_reason'),
|
|
'export_not_ready' => __('localization.review.export_not_ready_reason'),
|
|
'evidence_basis_missing' => __('localization.review.evidence_basis_missing_reason'),
|
|
'evidence_basis_stale' => __('localization.review.evidence_basis_stale_reason'),
|
|
'evidence_basis_incomplete' => __('localization.review.evidence_basis_incomplete_reason'),
|
|
'required_sections_incomplete' => __('localization.review.required_sections_incomplete_reason', [
|
|
'complete' => (int) data_get($outputReadiness, 'section_summary.required_complete', 0),
|
|
'total' => (int) data_get($outputReadiness, 'section_summary.required_total', 0),
|
|
'limited' => (int) data_get($outputReadiness, 'section_summary.required_limited', 0),
|
|
]),
|
|
'publish_blockers_present' => __('localization.review.publish_blockers_present_reason'),
|
|
'contains_pii' => __('localization.review.contains_pii_reason'),
|
|
'customer_safe_ready' => __('localization.review.customer_safe_review_pack_ready_reason'),
|
|
default => $packageAvailability['description'],
|
|
};
|
|
}
|
|
|
|
private function workspaceReadinessImpact(string $state, string $reasonCode): string
|
|
{
|
|
return match ($reasonCode) {
|
|
'findings_follow_up_required' => __('localization.review.findings_follow_up_required_impact'),
|
|
'accepted_risk_follow_up_required' => __('localization.review.accepted_risk_follow_up_required_impact'),
|
|
default => match ($state) {
|
|
ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => __('localization.review.customer_safe_review_pack_ready_impact'),
|
|
ReviewPackOutputReadiness::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE => __('localization.review.internal_review_package_available_impact'),
|
|
ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => __('localization.review.export_not_ready_impact'),
|
|
default => __('localization.review.published_with_limitations_impact'),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $baseCase
|
|
* @param array<string, mixed> $outputReadiness
|
|
* @param array{summary:string} $findingPanel
|
|
* @param array{state:string,label:string,description:string} $packageAvailability
|
|
* @param array{primary_label:string,primary_url:?string,primary_icon:string,secondary_label:?string,secondary_url:?string} $actions
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function workspaceFollowUpResolutionCase(
|
|
array $baseCase,
|
|
string $effectiveState,
|
|
string $reasonCode,
|
|
array $outputReadiness,
|
|
array $findingPanel,
|
|
array $packageAvailability,
|
|
array $actions,
|
|
): array {
|
|
$primaryAction = ResolutionAction::fromArray([
|
|
'key' => 'customer_review_workspace.'.$reasonCode.'.primary_action',
|
|
'label' => $actions['primary_label'],
|
|
'url' => $actions['primary_url'],
|
|
'icon' => $actions['primary_icon'],
|
|
'kind' => str_starts_with($actions['primary_icon'], 'heroicon-o-arrow-down-tray')
|
|
? 'download'
|
|
: 'environment_link',
|
|
], 'customer_review_workspace.'.$reasonCode.'.primary_action', $actions['primary_label']);
|
|
|
|
$secondaryActions = $actions['secondary_url'] !== null && $actions['secondary_label'] !== null
|
|
? [
|
|
ResolutionAction::fromArray([
|
|
'key' => 'customer_review_workspace.'.$reasonCode.'.secondary_action',
|
|
'label' => $actions['secondary_label'],
|
|
'url' => $actions['secondary_url'],
|
|
'icon' => 'heroicon-o-arrow-top-right-on-square',
|
|
'kind' => str_contains(strtolower($actions['secondary_label']), 'download')
|
|
? 'download'
|
|
: 'environment_link',
|
|
], 'customer_review_workspace.'.$reasonCode.'.secondary_action', $actions['secondary_label']),
|
|
]
|
|
: [];
|
|
|
|
return ResolutionCase::make(
|
|
key: 'customer_review_workspace.'.$reasonCode,
|
|
scope: is_array($baseCase['scope'] ?? null) ? $baseCase['scope'] : [],
|
|
severity: 'warning',
|
|
status: 'action_required',
|
|
title: $this->workspaceReadinessLabel($effectiveState),
|
|
reason: $this->workspaceReadinessReason(
|
|
reasonCode: $reasonCode,
|
|
outputReadiness: $outputReadiness,
|
|
findingPanel: $findingPanel,
|
|
packageAvailability: $packageAvailability,
|
|
),
|
|
impact: $this->workspaceReadinessImpact(
|
|
state: $effectiveState,
|
|
reasonCode: $reasonCode,
|
|
),
|
|
primaryAction: $primaryAction,
|
|
secondaryActions: $secondaryActions,
|
|
sourceRefs: is_array($baseCase['source_refs'] ?? null) ? $baseCase['source_refs'] : [],
|
|
evidenceRefs: is_array($baseCase['evidence_refs'] ?? null) ? $baseCase['evidence_refs'] : [],
|
|
technicalDetails: is_array($baseCase['technical_details'] ?? null) ? $baseCase['technical_details'] : [],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* primary_label:string,
|
|
* primary_url:?string,
|
|
* primary_icon:string,
|
|
* secondary_label:?string,
|
|
* secondary_url:?string
|
|
* }
|
|
*/
|
|
private function workspaceReadinessActions(
|
|
string $state,
|
|
string $reasonCode,
|
|
?string $downloadUrl,
|
|
?string $reviewUrl,
|
|
?string $evidenceUrl,
|
|
): array {
|
|
if (in_array($reasonCode, ['findings_follow_up_required', 'accepted_risk_follow_up_required'], true)) {
|
|
return [
|
|
'primary_label' => __('localization.review.open_review'),
|
|
'primary_url' => $reviewUrl ?? $evidenceUrl ?? $downloadUrl,
|
|
'primary_icon' => 'heroicon-o-arrow-top-right-on-square',
|
|
'secondary_label' => match ($state) {
|
|
ReviewPackOutputReadiness::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE => $downloadUrl !== null
|
|
? __('localization.review.download_internal_review_pack')
|
|
: null,
|
|
default => $downloadUrl !== null
|
|
? __('localization.review.download_review_pack_with_limitations')
|
|
: null,
|
|
},
|
|
'secondary_url' => $downloadUrl,
|
|
];
|
|
}
|
|
|
|
return match ($state) {
|
|
ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => [
|
|
'primary_label' => $downloadUrl !== null
|
|
? __('localization.review.download_customer_safe_review_pack')
|
|
: __('localization.review.open_latest_review'),
|
|
'primary_url' => $downloadUrl ?? $reviewUrl,
|
|
'primary_icon' => $downloadUrl !== null
|
|
? 'heroicon-o-arrow-down-tray'
|
|
: 'heroicon-o-arrow-top-right-on-square',
|
|
'secondary_label' => $downloadUrl !== null && $reviewUrl !== null
|
|
? __('localization.review.open_review')
|
|
: null,
|
|
'secondary_url' => $downloadUrl !== null ? $reviewUrl : null,
|
|
],
|
|
ReviewPackOutputReadiness::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE => [
|
|
'primary_label' => __('localization.review.review_package_contents'),
|
|
'primary_url' => $reviewUrl ?? $downloadUrl,
|
|
'primary_icon' => 'heroicon-o-arrow-top-right-on-square',
|
|
'secondary_label' => $downloadUrl !== null
|
|
? __('localization.review.download_internal_review_pack')
|
|
: null,
|
|
'secondary_url' => $downloadUrl,
|
|
],
|
|
ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => [
|
|
'primary_label' => __('localization.review.open_evidence_basis'),
|
|
'primary_url' => $evidenceUrl ?? $reviewUrl,
|
|
'primary_icon' => 'heroicon-o-arrow-top-right-on-square',
|
|
'secondary_label' => $reviewUrl !== null && $reviewUrl !== $evidenceUrl
|
|
? __('localization.review.open_review')
|
|
: null,
|
|
'secondary_url' => $reviewUrl !== $evidenceUrl ? $reviewUrl : null,
|
|
],
|
|
default => [
|
|
'primary_label' => __('localization.review.review_output_limitations'),
|
|
'primary_url' => $reviewUrl ?? $evidenceUrl ?? $downloadUrl,
|
|
'primary_icon' => 'heroicon-o-arrow-top-right-on-square',
|
|
'secondary_label' => $downloadUrl !== null
|
|
? __('localization.review.download_review_pack_with_limitations')
|
|
: null,
|
|
'secondary_url' => $downloadUrl,
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $resolutionCase
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function decorateSuccessorResolutionCase(array $resolutionCase, EnvironmentReview $review): array
|
|
{
|
|
if (data_get($resolutionCase, 'primary_action.key') !== 'open_successor_review') {
|
|
return $resolutionCase;
|
|
}
|
|
|
|
$successor = $this->successorReviewForReview($review);
|
|
|
|
if (! $successor instanceof EnvironmentReview || ! $successor->isMutable()) {
|
|
return $resolutionCase;
|
|
}
|
|
|
|
$canPublishSuccessor = app(\App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate::class)->canPublish($successor);
|
|
|
|
return array_replace($resolutionCase, [
|
|
'title' => __('localization.review.draft_review_exists'),
|
|
'reason' => $canPublishSuccessor
|
|
? __('localization.review.draft_review_exists_ready_reason')
|
|
: __('localization.review.draft_review_exists_blocked_reason'),
|
|
'impact' => $canPublishSuccessor
|
|
? __('localization.review.draft_review_exists_ready_impact')
|
|
: __('localization.review.draft_review_exists_blocked_impact'),
|
|
]);
|
|
}
|
|
|
|
private function successorReviewForReview(EnvironmentReview $review): ?EnvironmentReview
|
|
{
|
|
if ($review->relationLoaded('supersededByReview')) {
|
|
return $review->supersededByReview instanceof EnvironmentReview
|
|
? $review->supersededByReview
|
|
: null;
|
|
}
|
|
|
|
if (! is_numeric($review->superseded_by_review_id)) {
|
|
return null;
|
|
}
|
|
|
|
return EnvironmentReview::query()
|
|
->with(['tenant', 'sections', 'evidenceSnapshot', 'currentExportReviewPack'])
|
|
->whereKey((int) $review->superseded_by_review_id)
|
|
->where('workspace_id', (int) $review->workspace_id)
|
|
->where('managed_environment_id', (int) $review->managed_environment_id)
|
|
->first();
|
|
}
|
|
|
|
private function successorReviewStatusForReview(EnvironmentReview $review): ?string
|
|
{
|
|
return $this->successorReviewForReview($review)?->status;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $resolutionCase
|
|
* @param array<string, mixed> $outputGuidance
|
|
*/
|
|
private function workspaceActionHelpForResolutionCase(array $resolutionCase, array $outputGuidance): ?string
|
|
{
|
|
$primaryActionKey = is_string(data_get($resolutionCase, 'primary_action.key'))
|
|
? (string) data_get($resolutionCase, 'primary_action.key')
|
|
: null;
|
|
|
|
return match ($primaryActionKey) {
|
|
'create_next_review' => __('localization.review.output_action_help_create_next_review'),
|
|
'refresh_review' => __('localization.review.output_action_help_refresh_review'),
|
|
'publish_review' => __('localization.review.output_action_help_publish_review'),
|
|
'open_successor_review' => data_get($resolutionCase, 'title') === __('localization.review.draft_review_exists')
|
|
? __('localization.review.output_action_help_open_draft_review')
|
|
: __('localization.review.output_action_help_open_successor_review'),
|
|
default => is_string($outputGuidance['action_help'] ?? null) ? (string) $outputGuidance['action_help'] : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array{state:string,label:string,description:string} $packageAvailability
|
|
* @param array<string, mixed> $outputReadiness
|
|
*/
|
|
private function reviewPackPanelDescription(array $packageAvailability, array $outputReadiness): string
|
|
{
|
|
if ($packageAvailability['state'] !== 'available') {
|
|
return $packageAvailability['description'];
|
|
}
|
|
|
|
return match ((string) ($outputReadiness['readiness_state'] ?? ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY)) {
|
|
ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => __('localization.review.review_pack_customer_safe_ready_description'),
|
|
ReviewPackOutputReadiness::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE => __('localization.review.review_pack_internal_review_description'),
|
|
ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => __('localization.review.review_pack_export_not_ready_description'),
|
|
default => __('localization.review.review_pack_with_limitations_description'),
|
|
};
|
|
}
|
|
|
|
private function reviewPackPackageExistsDescription(string $state): string
|
|
{
|
|
return match ($state) {
|
|
'available' => __('localization.review.package_exists_available_description'),
|
|
'preparing' => __('localization.review.package_exists_preparing_description'),
|
|
default => __('localization.review.package_exists_unavailable_description'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array{state:string,label:string,description:string} $packageAvailability
|
|
*/
|
|
private function reviewPackInternalExportDescription(array $packageAvailability, ?string $downloadUrl): string
|
|
{
|
|
if ($downloadUrl !== null) {
|
|
return __('localization.review.internal_export_ready_description');
|
|
}
|
|
|
|
return match ($packageAvailability['state']) {
|
|
'preparing' => __('localization.review.internal_export_preparing_description'),
|
|
'available' => __('localization.review.internal_export_not_ready_description'),
|
|
default => __('localization.review.internal_export_unavailable_description'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $outputReadiness
|
|
*/
|
|
private function reviewPackCustomerSharingDescription(array $outputReadiness): string
|
|
{
|
|
return match ((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review')) {
|
|
ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => __('localization.review.customer_sharing_ready_description'),
|
|
'internal_only' => __('localization.review.customer_sharing_internal_only_description'),
|
|
default => __('localization.review.customer_sharing_requires_review_description'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $sectionSummary
|
|
*/
|
|
private function sectionCompletenessLabel(array $sectionSummary): string
|
|
{
|
|
$requiredTotal = (int) ($sectionSummary['required_total'] ?? 0);
|
|
$requiredComplete = (int) ($sectionSummary['required_complete'] ?? 0);
|
|
$requiredLimited = (int) ($sectionSummary['required_limited'] ?? 0);
|
|
|
|
if ($requiredTotal <= 0) {
|
|
return __('localization.review.unavailable');
|
|
}
|
|
|
|
if ($requiredLimited > 0) {
|
|
return __('localization.review.section_completeness_limited', [
|
|
'complete' => $requiredComplete,
|
|
'total' => $requiredTotal,
|
|
'limited' => $requiredLimited,
|
|
]);
|
|
}
|
|
|
|
return __('localization.review.section_completeness_complete', [
|
|
'complete' => $requiredComplete,
|
|
'total' => $requiredTotal,
|
|
]);
|
|
}
|
|
|
|
private function workspaceCustomerOutputState(ManagedEnvironment $tenant): string
|
|
{
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return 'not_ready';
|
|
}
|
|
|
|
if ($this->primaryControlSummary($tenant) === null || $this->evidenceStatusState($tenant) !== 'available') {
|
|
return 'not_ready';
|
|
}
|
|
|
|
$effectiveState = $this->effectiveWorkspaceReadinessState(
|
|
$this->reviewPackOutputReadinessForReview($review),
|
|
$this->findingPanelForReview($tenant)['open_count'] > 0,
|
|
$this->acceptedRiskFollowUpRequiredForReview($review),
|
|
);
|
|
|
|
return match ($effectiveState) {
|
|
ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => 'ready',
|
|
ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => 'not_ready',
|
|
default => 'needs_review',
|
|
};
|
|
}
|
|
|
|
private function customerSafeText(mixed $value, string $fallback, int $limit = 220): string
|
|
{
|
|
if (! is_string($value) || trim($value) === '') {
|
|
return $fallback;
|
|
}
|
|
|
|
return Str::limit(trim($value), $limit);
|
|
}
|
|
|
|
private function controlReadinessColor(ManagedEnvironment $tenant): string
|
|
{
|
|
return match ((string) ($this->primaryControlSummary($tenant)['readiness_bucket'] ?? 'unmapped')) {
|
|
'follow_up_required' => 'warning',
|
|
'review_recommended' => 'info',
|
|
'evidence_on_record' => 'success',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
|
|
private function controlReadinessDescription(ManagedEnvironment $tenant): string
|
|
{
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return __('localization.review.no_published_review_available');
|
|
}
|
|
|
|
$controls = $review->controlInterpretationControls();
|
|
|
|
if ($controls === []) {
|
|
return __('localization.review.control_readiness_unmapped_description');
|
|
}
|
|
|
|
$summary = collect($controls)
|
|
->take(2)
|
|
->map(function (array $control): string {
|
|
$name = is_string($control['control_name'] ?? null) ? $control['control_name'] : __('localization.review.control');
|
|
$label = is_string($control['readiness_label'] ?? null)
|
|
? $control['readiness_label']
|
|
: ComplianceEvidenceMappingV1::readinessLabel((string) ($control['readiness_bucket'] ?? 'review_recommended'));
|
|
|
|
return $name.': '.$label;
|
|
})
|
|
->implode(' · ');
|
|
|
|
$remaining = count($controls) - 2;
|
|
|
|
if ($remaining > 0) {
|
|
$summary .= ' · '.__('localization.review.additional_controls', ['count' => $remaining]);
|
|
}
|
|
|
|
$limitations = $this->controlLimitationSummary($review);
|
|
|
|
return trim($summary.($limitations !== null ? ' '.$limitations : ''));
|
|
}
|
|
|
|
private function controlEvidenceBasisSummary(ManagedEnvironment $tenant): string
|
|
{
|
|
$control = $this->primaryControlSummary($tenant);
|
|
|
|
if ($control === null) {
|
|
return __('localization.review.control_evidence_unmapped');
|
|
}
|
|
|
|
$summary = $control['evidence_basis_summary'] ?? null;
|
|
|
|
return is_string($summary) && trim($summary) !== ''
|
|
? $summary
|
|
: __('localization.review.control_evidence_unavailable');
|
|
}
|
|
|
|
private function controlRecommendedNextAction(ManagedEnvironment $tenant): string
|
|
{
|
|
if ($this->primaryControlSummary($tenant) === null) {
|
|
return __('localization.review.workspace_next_step_control_mapping');
|
|
}
|
|
|
|
if ($this->evidenceStatusState($tenant) !== 'available') {
|
|
return __('localization.review.workspace_next_step_evidence_review');
|
|
}
|
|
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return __('localization.review.workspace_next_step_review_open');
|
|
}
|
|
|
|
$readinessState = $this->effectiveWorkspaceReadinessState(
|
|
$this->reviewPackOutputReadinessForReview($review),
|
|
$this->findingPanelForReview($tenant)['open_count'] > 0,
|
|
$this->acceptedRiskFollowUpRequiredForReview($review),
|
|
);
|
|
|
|
return match (true) {
|
|
$readinessState === ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => __('localization.review.workspace_next_step_evidence_review'),
|
|
$this->governancePackageAvailability($tenant)['state'] === 'available' => __('localization.review.workspace_next_step_package_review'),
|
|
default => __('localization.review.workspace_next_step_review_open'),
|
|
};
|
|
}
|
|
|
|
private function workspaceReviewNeedsAttention(ManagedEnvironment $tenant): bool
|
|
{
|
|
return $this->workspaceCustomerOutputState($tenant) !== 'ready';
|
|
}
|
|
|
|
private function evidenceStatusState(ManagedEnvironment $tenant): string
|
|
{
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return 'pending';
|
|
}
|
|
|
|
$snapshot = $review->evidenceSnapshot;
|
|
$user = auth()->user();
|
|
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
return 'pending';
|
|
}
|
|
|
|
if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
|
return 'restricted';
|
|
}
|
|
|
|
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
|
return 'expired';
|
|
}
|
|
|
|
return 'available';
|
|
}
|
|
|
|
private function evidenceStatusLabelForState(string $state): string
|
|
{
|
|
return match ($state) {
|
|
'available' => __('localization.review.available'),
|
|
'restricted' => __('localization.review.restricted'),
|
|
'expired' => __('localization.review.expired'),
|
|
default => __('localization.review.pending'),
|
|
};
|
|
}
|
|
|
|
private function evidenceStatusColorForState(string $state): string
|
|
{
|
|
return match ($state) {
|
|
'available' => 'success',
|
|
'restricted', 'expired' => 'danger',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
|
|
private function controlRecommendedNextActionDescription(ManagedEnvironment $tenant): string
|
|
{
|
|
$control = $this->primaryControlSummary($tenant);
|
|
|
|
if ($control === null) {
|
|
return __('localization.review.control_recommendation_unmapped');
|
|
}
|
|
|
|
$action = $control['recommended_next_action'] ?? null;
|
|
|
|
return is_string($action) && trim($action) !== ''
|
|
? $action
|
|
: __('localization.review.no_action_needed');
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function primaryControlSummary(ManagedEnvironment $tenant): ?array
|
|
{
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return null;
|
|
}
|
|
|
|
$controls = collect($review->controlInterpretationControls());
|
|
|
|
return $controls
|
|
->sortBy(static fn (array $control): int => match ((string) ($control['readiness_bucket'] ?? '')) {
|
|
'follow_up_required' => 0,
|
|
'review_recommended' => 1,
|
|
'evidence_on_record' => 2,
|
|
default => 3,
|
|
})
|
|
->first();
|
|
}
|
|
|
|
private function controlLimitationSummary(EnvironmentReview $review): ?string
|
|
{
|
|
$counts = $review->controlInterpretationLimitationCounts();
|
|
|
|
if ($counts === []) {
|
|
return null;
|
|
}
|
|
|
|
$labels = collect($counts)
|
|
->filter(static fn (int $count): bool => $count > 0)
|
|
->keys()
|
|
->map(static fn (string $flag): string => ComplianceEvidenceMappingV1::limitationLabel($flag))
|
|
->values()
|
|
->all();
|
|
|
|
return $labels === []
|
|
? null
|
|
: __('localization.review.control_limitations_summary', ['limitations' => implode(', ', $labels)]);
|
|
}
|
|
|
|
private function findingSummary(ManagedEnvironment $tenant): string
|
|
{
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return __('localization.review.no_published_review_available');
|
|
}
|
|
|
|
$summary = is_array($review->summary) ? $review->summary : [];
|
|
$findingCount = (int) ($summary['finding_count'] ?? 0);
|
|
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
|
$terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
|
|
|
|
if ($findingCount === 0) {
|
|
return __('localization.review.no_findings_recorded');
|
|
}
|
|
|
|
if ($terminalOutcomes === null) {
|
|
return __('localization.review.findings_count_summary', ['count' => $findingCount]);
|
|
}
|
|
|
|
return __('localization.review.findings_count_with_outcomes', [
|
|
'count' => $findingCount,
|
|
'outcomes' => $terminalOutcomes,
|
|
]);
|
|
}
|
|
|
|
private function acceptedRiskSummary(ManagedEnvironment $tenant): string
|
|
{
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return __('localization.review.no_published_review_available');
|
|
}
|
|
|
|
$summary = is_array($review->summary) ? $review->summary : [];
|
|
$riskAcceptance = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
|
|
$statusMarkedCount = (int) ($riskAcceptance['status_marked_count'] ?? 0);
|
|
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
|
|
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
|
|
|
|
$countSummary = match (true) {
|
|
$statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'),
|
|
$warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
|
|
$validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
|
|
default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]),
|
|
};
|
|
|
|
$accountability = $this->acceptedRiskAccountability($tenant);
|
|
|
|
return $accountability === null
|
|
? $countSummary
|
|
: $countSummary.' '.$accountability;
|
|
}
|
|
|
|
private function evidenceProofAvailability(ManagedEnvironment $tenant): string
|
|
{
|
|
$review = $this->latestPublishedReview($tenant);
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return __('localization.review.no_published_review_available');
|
|
}
|
|
|
|
$snapshot = $review->evidenceSnapshot;
|
|
$user = auth()->user();
|
|
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
return __('localization.review.evidence_proof_absent');
|
|
}
|
|
|
|
if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
|
return __('localization.review.evidence_proof_access_unavailable');
|
|
}
|
|
|
|
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
|
return __('localization.review.evidence_proof_expired');
|
|
}
|
|
|
|
return __('localization.review.evidence_proof_available');
|
|
}
|
|
|
|
private function evidenceStatusLabel(ManagedEnvironment $tenant): string
|
|
{
|
|
return $this->evidenceStatusLabelForState($this->evidenceStatusState($tenant));
|
|
}
|
|
|
|
private function evidenceStatusColor(ManagedEnvironment $tenant): string
|
|
{
|
|
return $this->evidenceStatusColorForState($this->evidenceStatusState($tenant));
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function visibleInterpretationVersions(): array
|
|
{
|
|
$user = auth()->user();
|
|
$workspace = $this->workspace();
|
|
|
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
return [];
|
|
}
|
|
|
|
return app(EnvironmentReviewRegisterService::class)
|
|
->latestPublishedQuery($user, $workspace)
|
|
->get()
|
|
->map(static fn (EnvironmentReview $review): ?string => $review->controlInterpretationVersion())
|
|
->filter()
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function currentTenantFilterInterpretationVersion(): ?string
|
|
{
|
|
$tenant = $this->filteredTenant();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return null;
|
|
}
|
|
|
|
return $this->latestPublishedReview($tenant)?->controlInterpretationVersion();
|
|
}
|
|
|
|
private function acceptedRiskAccountability(ManagedEnvironment $tenant): ?string
|
|
{
|
|
$exception = FindingException::query()
|
|
->with(['owner', 'approver', 'currentDecision'])
|
|
->where('workspace_id', (int) $tenant->workspace_id)
|
|
->where('managed_environment_id', (int) $tenant->getKey())
|
|
->current()
|
|
->orderByRaw("case when current_validity_state in ('valid', 'expiring') then 0 else 1 end")
|
|
->latest('approved_at')
|
|
->latest('requested_at')
|
|
->latest('id')
|
|
->first();
|
|
|
|
if (! $exception instanceof FindingException) {
|
|
return null;
|
|
}
|
|
|
|
$accountable = $exception->owner?->name
|
|
?? $exception->approver?->name;
|
|
$decisionType = $exception->currentDecision?->decision_type;
|
|
$reviewDue = $exception->review_due_at ?? $exception->expires_at;
|
|
$reason = is_string($exception->request_reason) ? trim($exception->request_reason) : '';
|
|
$parts = [];
|
|
|
|
if (is_string($accountable) && trim($accountable) !== '') {
|
|
$parts[] = $reviewDue === null
|
|
? __('localization.review.accepted_risk_accountable', ['name' => $accountable])
|
|
: __('localization.review.accepted_risk_accountable_until', [
|
|
'name' => $accountable,
|
|
'date' => $reviewDue->toDateString(),
|
|
]);
|
|
} elseif (is_string($decisionType) && trim($decisionType) !== '') {
|
|
$parts[] = __('localization.review.accepted_risk_partial_accountability');
|
|
}
|
|
|
|
if ($reason !== '') {
|
|
$parts[] = __('localization.review.accepted_risk_reason', [
|
|
'reason' => Str::limit($reason, 160),
|
|
]);
|
|
}
|
|
|
|
return $parts === [] ? null : implode(' ', $parts);
|
|
}
|
|
|
|
private function navigationContext(): ?CanonicalNavigationContext
|
|
{
|
|
return CanonicalNavigationContext::fromRequest(request());
|
|
}
|
|
|
|
private function incomingGovernanceContext(): ?CanonicalNavigationContext
|
|
{
|
|
$context = $this->navigationContext();
|
|
|
|
return $context?->sourceSurface === 'governance.inbox'
|
|
? $context
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
}
|
|
}
|