TenantAtlas/apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php
ahmido d4e4d2d109 feat: review output resolve actions v1 (spec 351) (#422)
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
2026-06-04 00:55:02 +00:00

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