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
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,
|
|
);
|
|
}
|
|
}
|