Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m45s
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.
533 lines
20 KiB
PHP
533 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Resources\EnvironmentReviewResource\Pages;
|
|
|
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
use App\Filament\Resources\EnvironmentReviewResource;
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\User;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate;
|
|
use App\Services\EnvironmentReviews\EnvironmentReviewLifecycleService;
|
|
use App\Services\EnvironmentReviews\EnvironmentReviewService;
|
|
use App\Services\ReviewPackService;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\EnvironmentReviewStatus;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
use App\Support\ReviewPackStatus;
|
|
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
|
use Filament\Actions;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Pages\ViewRecord;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
class ViewEnvironmentReview extends ViewRecord
|
|
{
|
|
protected static string $resource = EnvironmentReviewResource::class;
|
|
|
|
/**
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
private ?array $outputGuidanceStateCache = null;
|
|
|
|
public function mount(int|string $record): void
|
|
{
|
|
parent::mount($record);
|
|
|
|
$this->auditCustomerWorkspaceOpen();
|
|
}
|
|
|
|
protected function resolveRecord(int|string $key): Model
|
|
{
|
|
return EnvironmentReviewResource::resolveScopedRecordOrFail($key);
|
|
}
|
|
|
|
protected function authorizeAccess(): void
|
|
{
|
|
$tenant = EnvironmentReviewResource::panelTenantContext();
|
|
$record = $this->getRecord();
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment || ! $record instanceof EnvironmentReview) {
|
|
abort(404);
|
|
}
|
|
|
|
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $user->canAccessTenant($tenant)) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $user->can('view', $record)) {
|
|
abort(403);
|
|
}
|
|
}
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
if ($this->isCustomerWorkspaceView()) {
|
|
return [
|
|
$this->downloadCurrentReviewPackAction(),
|
|
];
|
|
}
|
|
|
|
$secondaryActions = $this->secondaryLifecycleActions();
|
|
|
|
return array_values(array_filter([
|
|
$this->primaryLifecycleAction(),
|
|
Actions\ActionGroup::make($secondaryActions)
|
|
->label('More')
|
|
->icon('heroicon-m-ellipsis-vertical')
|
|
->color('gray')
|
|
->visible(fn (): bool => $secondaryActions !== []),
|
|
Actions\ActionGroup::make([
|
|
$this->archiveReviewAction(),
|
|
])
|
|
->label('Danger')
|
|
->icon('heroicon-o-archive-box')
|
|
->color('danger')
|
|
->visible(fn (): bool => ! $this->isCustomerWorkspaceView() && ! $this->record->statusEnum()->isTerminal()),
|
|
]));
|
|
}
|
|
|
|
private function primaryLifecycleAction(): ?Actions\Action
|
|
{
|
|
return match ($this->primaryLifecycleActionName()) {
|
|
'refresh_review' => $this->refreshReviewAction(),
|
|
'publish_review' => $this->publishReviewAction(),
|
|
'create_next_review' => $this->createNextReviewAction(),
|
|
'export_executive_pack' => $this->exportExecutivePackAction(),
|
|
'open_successor_review' => $this->openSuccessorReviewAction(),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function primaryLifecycleActionName(): ?string
|
|
{
|
|
if ($this->isCustomerWorkspaceView()) {
|
|
return null;
|
|
}
|
|
|
|
$mappedPrimaryActionName = $this->mappedPrimaryLifecycleActionName();
|
|
|
|
if ($mappedPrimaryActionName !== null) {
|
|
return $mappedPrimaryActionName;
|
|
}
|
|
|
|
if ((string) $this->record->status === EnvironmentReviewStatus::Published->value) {
|
|
return 'export_executive_pack';
|
|
}
|
|
|
|
if ((string) $this->record->status === EnvironmentReviewStatus::Ready->value) {
|
|
return 'publish_review';
|
|
}
|
|
|
|
if ($this->record->isMutable()) {
|
|
return 'refresh_review';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return list<Actions\Action>
|
|
*/
|
|
private function secondaryLifecycleActions(): array
|
|
{
|
|
return array_values(array_filter(array_map(
|
|
fn (string $name): ?Actions\Action => match ($name) {
|
|
'refresh_review' => $this->refreshReviewAction(),
|
|
'publish_review' => $this->publishReviewAction(),
|
|
'export_executive_pack' => $this->exportExecutivePackAction(),
|
|
'create_next_review' => $this->createNextReviewAction(),
|
|
'open_successor_review' => $this->openSuccessorReviewAction(),
|
|
default => null,
|
|
},
|
|
$this->secondaryLifecycleActionNames(),
|
|
)));
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function secondaryLifecycleActionNames(): array
|
|
{
|
|
if ($this->isCustomerWorkspaceView()) {
|
|
return [];
|
|
}
|
|
|
|
$names = [];
|
|
|
|
if ($this->record->isMutable()) {
|
|
$names[] = 'refresh_review';
|
|
$names[] = 'publish_review';
|
|
}
|
|
|
|
if (in_array((string) $this->record->status, [
|
|
EnvironmentReviewStatus::Ready->value,
|
|
EnvironmentReviewStatus::Published->value,
|
|
], true)) {
|
|
$names[] = 'export_executive_pack';
|
|
}
|
|
|
|
if ($this->record->isPublished()) {
|
|
$names[] = 'create_next_review';
|
|
}
|
|
|
|
return array_values(array_filter(
|
|
$names,
|
|
fn (string $name): bool => $name !== $this->primaryLifecycleActionName(),
|
|
));
|
|
}
|
|
|
|
private function refreshReviewAction(): Actions\Action
|
|
{
|
|
$rule = GovernanceActionCatalog::rule('refresh_review');
|
|
|
|
return UiEnforcement::forAction(
|
|
Actions\Action::make('refresh_review')
|
|
->label($rule->canonicalLabel)
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('primary')
|
|
->hidden(fn (): bool => ! $this->record->isMutable())
|
|
->requiresConfirmation()
|
|
->modalHeading($rule->modalHeading)
|
|
->modalDescription($rule->modalDescription)
|
|
->action(function () use ($rule): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
try {
|
|
app(EnvironmentReviewService::class)->refresh($this->record, $user);
|
|
} catch (\Throwable $throwable) {
|
|
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$this->record->refresh();
|
|
$this->record->loadMissing(['tenant', 'sections', 'evidenceSnapshot', 'currentExportReviewPack', 'operationRun']);
|
|
$this->outputGuidanceStateCache = null;
|
|
Notification::make()
|
|
->success()
|
|
->title($rule->successTitle)
|
|
->body($this->refreshReviewFeedbackBody())
|
|
->send();
|
|
}),
|
|
)
|
|
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
|
->apply();
|
|
}
|
|
|
|
private function publishReviewAction(): Actions\Action
|
|
{
|
|
$rule = GovernanceActionCatalog::rule('publish_review');
|
|
|
|
return UiEnforcement::forAction(
|
|
Actions\Action::make('publish_review')
|
|
->label($rule->canonicalLabel)
|
|
->icon('heroicon-o-check-badge')
|
|
->color('primary')
|
|
->hidden(fn (): bool => ! $this->record->isMutable())
|
|
->disabled(fn (): bool => ! $this->canPublishCurrentReview())
|
|
->tooltip(fn (): ?string => ! $this->canPublishCurrentReview()
|
|
? __('localization.review.resolve_review_blockers_before_publishing')
|
|
: null)
|
|
->requiresConfirmation()
|
|
->modalHeading($rule->modalHeading)
|
|
->modalDescription($rule->modalDescription)
|
|
->form([
|
|
Textarea::make('publish_reason')
|
|
->label('Publication reason')
|
|
->rows(4)
|
|
->required()
|
|
->maxLength(2000),
|
|
])
|
|
->action(function (array $data) use ($rule): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
try {
|
|
app(EnvironmentReviewLifecycleService::class)->publish(
|
|
$this->record,
|
|
$user,
|
|
(string) ($data['publish_reason'] ?? ''),
|
|
);
|
|
} catch (\Throwable $throwable) {
|
|
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
|
|
$this->outputGuidanceStateCache = null;
|
|
Notification::make()->success()->title($rule->successTitle)->send();
|
|
}),
|
|
)
|
|
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
|
->preserveVisibility()
|
|
->preserveDisabled()
|
|
->apply();
|
|
}
|
|
|
|
private function exportExecutivePackAction(): Actions\Action
|
|
{
|
|
$action = UiEnforcement::forAction(
|
|
Actions\Action::make('export_executive_pack')
|
|
->label('Export executive pack')
|
|
->icon('heroicon-o-arrow-down-tray')
|
|
->color('primary')
|
|
->hidden(fn (): bool => ! in_array((string) $this->record->status, [
|
|
EnvironmentReviewStatus::Ready->value,
|
|
EnvironmentReviewStatus::Published->value,
|
|
], true))
|
|
->disabled(fn (): bool => EnvironmentReviewResource::reviewPackGenerationBlocked($this->record->tenant))
|
|
->action(fn (): mixed => EnvironmentReviewResource::executeExport($this->record)),
|
|
)
|
|
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
|
->preserveVisibility()
|
|
->preserveDisabled()
|
|
->apply();
|
|
|
|
$action->tooltip(fn (): ?string => EnvironmentReviewResource::reviewPackGenerationActionTooltip($this->record->tenant));
|
|
|
|
return $action;
|
|
}
|
|
|
|
private function createNextReviewAction(): Actions\Action
|
|
{
|
|
return UiEnforcement::forAction(
|
|
Actions\Action::make('create_next_review')
|
|
->label(__('localization.review.create_next_review'))
|
|
->icon('heroicon-o-document-duplicate')
|
|
->color('primary')
|
|
->hidden(fn (): bool => ! $this->record->isPublished())
|
|
->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();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
try {
|
|
$nextReview = app(EnvironmentReviewLifecycleService::class)->createNextReview($this->record, $user);
|
|
} catch (\Throwable $throwable) {
|
|
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
|
|
|
|
return;
|
|
}
|
|
|
|
Notification::make()->success()->title(__('localization.review.create_next_review_success'))->send();
|
|
$this->redirect(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
|
|
}),
|
|
)
|
|
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
|
->preserveVisibility()
|
|
->apply();
|
|
}
|
|
|
|
private function openSuccessorReviewAction(): Actions\Action
|
|
{
|
|
return Actions\Action::make('open_successor_review')
|
|
->label(__('localization.review.open_successor_review'))
|
|
->icon('heroicon-o-arrow-top-right-on-square')
|
|
->color('primary')
|
|
->visible(fn (): bool => $this->successorReviewUrl() !== null)
|
|
->url(fn (): ?string => $this->successorReviewUrl());
|
|
}
|
|
|
|
private function archiveReviewAction(): Actions\Action
|
|
{
|
|
$rule = GovernanceActionCatalog::rule('archive_review');
|
|
|
|
return UiEnforcement::forAction(
|
|
Actions\Action::make('archive_review')
|
|
->label($rule->canonicalLabel)
|
|
->icon('heroicon-o-archive-box')
|
|
->color('danger')
|
|
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
|
|
->requiresConfirmation()
|
|
->modalHeading($rule->modalHeading)
|
|
->modalDescription($rule->modalDescription)
|
|
->form([
|
|
Textarea::make('archive_reason')
|
|
->label('Archive reason')
|
|
->rows(4)
|
|
->required()
|
|
->maxLength(2000),
|
|
])
|
|
->action(function (array $data) use ($rule): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
app(EnvironmentReviewLifecycleService::class)->archive(
|
|
$this->record,
|
|
$user,
|
|
(string) ($data['archive_reason'] ?? ''),
|
|
);
|
|
$this->refreshFormData(['status', 'archived_at']);
|
|
$this->outputGuidanceStateCache = null;
|
|
|
|
Notification::make()->success()->title($rule->successTitle)->send();
|
|
}),
|
|
)
|
|
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
|
->preserveVisibility()
|
|
->apply();
|
|
}
|
|
|
|
private function downloadCurrentReviewPackAction(): Actions\Action
|
|
{
|
|
return Actions\Action::make('download_current_review_pack')
|
|
->label(function (): string {
|
|
$guidance = EnvironmentReviewResource::outputGuidanceState($this->record);
|
|
|
|
return (string) ($guidance['qualified_download_label'] ?? __('localization.review.download_governance_package'));
|
|
})
|
|
->icon('heroicon-o-arrow-down-tray')
|
|
->color('primary')
|
|
->disabled(fn (): bool => $this->currentReviewPackDownloadUrl() === null)
|
|
->tooltip(fn (): ?string => $this->currentReviewPackUnavailableReason())
|
|
->url(fn (): ?string => $this->currentReviewPackDownloadUrl())
|
|
->openUrlInNewTab();
|
|
}
|
|
|
|
private function currentReviewPackDownloadUrl(): ?string
|
|
{
|
|
return EnvironmentReviewResource::currentReviewPackDownloadUrlFor($this->record);
|
|
}
|
|
|
|
private function currentReviewPackUnavailableReason(): ?string
|
|
{
|
|
if ($this->currentReviewPackDownloadUrl() !== null) {
|
|
return null;
|
|
}
|
|
|
|
$pack = $this->record->currentExportReviewPack;
|
|
$tenant = $this->record->tenant;
|
|
$user = auth()->user();
|
|
|
|
if (! $pack instanceof ReviewPack) {
|
|
return __('localization.review.customer_review_pack_missing');
|
|
}
|
|
|
|
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
|
return __('localization.review.customer_review_pack_forbidden');
|
|
}
|
|
|
|
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
|
return __('localization.review.customer_review_pack_not_ready');
|
|
}
|
|
|
|
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
|
return __('localization.review.customer_review_pack_expired');
|
|
}
|
|
|
|
return __('localization.review.customer_review_pack_unavailable');
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function outputGuidanceState(): array
|
|
{
|
|
return $this->outputGuidanceStateCache ??= EnvironmentReviewResource::outputGuidanceState($this->record);
|
|
}
|
|
|
|
private function canPublishCurrentReview(): bool
|
|
{
|
|
return app(EnvironmentReviewReadinessGate::class)->canPublish($this->record);
|
|
}
|
|
|
|
private function refreshReviewFeedbackBody(): string
|
|
{
|
|
return data_get($this->outputGuidanceState(), 'resolution_case.primary_action.key') === 'publish_review'
|
|
? __('localization.review.refresh_review_feedback_ready')
|
|
: __('localization.review.refresh_review_feedback_blocked');
|
|
}
|
|
|
|
private function mappedPrimaryLifecycleActionName(): ?string
|
|
{
|
|
$actionName = data_get($this->outputGuidanceState(), 'resolution_case.primary_action.action_name');
|
|
|
|
return is_string($actionName) && in_array($actionName, [
|
|
'refresh_review',
|
|
'publish_review',
|
|
'create_next_review',
|
|
'open_successor_review',
|
|
], true)
|
|
? $actionName
|
|
: null;
|
|
}
|
|
|
|
private function successorReviewUrl(): ?string
|
|
{
|
|
$url = data_get($this->outputGuidanceState(), 'resolution_case.primary_action.url');
|
|
$actionName = data_get($this->outputGuidanceState(), 'resolution_case.primary_action.action_name');
|
|
|
|
if ($actionName !== 'open_successor_review' || ! is_string($url) || trim($url) === '') {
|
|
return null;
|
|
}
|
|
|
|
return trim($url);
|
|
}
|
|
|
|
private function isCustomerWorkspaceView(): bool
|
|
{
|
|
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
|
}
|
|
|
|
private function auditCustomerWorkspaceOpen(): void
|
|
{
|
|
if (! $this->isCustomerWorkspaceView()) {
|
|
return;
|
|
}
|
|
|
|
$user = auth()->user();
|
|
$tenant = $this->record->tenant;
|
|
|
|
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
|
return;
|
|
}
|
|
|
|
app(WorkspaceAuditLogger::class)->log(
|
|
workspace: $tenant->workspace,
|
|
action: AuditActionId::EnvironmentReviewOpened,
|
|
context: [
|
|
'metadata' => [
|
|
'review_id' => (int) $this->record->getKey(),
|
|
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
|
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
|
'interpretation_version' => $this->record->controlInterpretationVersion(),
|
|
],
|
|
],
|
|
actor: $user,
|
|
resourceType: 'environment_review',
|
|
resourceId: (string) $this->record->getKey(),
|
|
targetLabel: sprintf('ManagedEnvironment review #%d', (int) $this->record->getKey()),
|
|
tenant: $tenant,
|
|
);
|
|
}
|
|
}
|