TenantAtlas/apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php
ahmido 9cd06e8b66 feat: review pack pdf and html renderer v1 (spec 356) (#427)
Implemented the first version of the PDF and HTML renderer for review packs. Added ReviewPackRenderedReportController and related blade views to render reports. Updated EnvironmentReviewResource, ReviewPackResource, ReviewPackService, and routing. Added new tests for the renderer and download actions, and updated UI documentation.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #427
2026-06-05 20:39:13 +00:00

529 lines
19 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->openCurrentRenderedReportAction(),
];
}
$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 openCurrentRenderedReportAction(): Actions\Action
{
return Actions\Action::make('open_current_rendered_report')
->label(fn (): string => EnvironmentReviewResource::renderedReportActionLabelFor($this->record))
->icon('heroicon-o-document-text')
->color('primary')
->disabled(fn (): bool => $this->currentRenderedReportUrl() === null)
->tooltip(fn (): ?string => $this->currentRenderedReportUnavailableReason())
->url(fn (): ?string => $this->currentRenderedReportUrl())
->openUrlInNewTab();
}
private function currentRenderedReportUrl(): ?string
{
return EnvironmentReviewResource::currentRenderedReportUrlFor($this->record);
}
private function currentRenderedReportUnavailableReason(): ?string
{
if ($this->currentRenderedReportUrl() !== 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,
);
}
}