TenantAtlas/apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ResolveReviewPublication.php
ahmido aca0b10658 feat: add review publication resolution ux spec and tests (#458)
Automated PR created by Codex via Gitea API.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #458
2026-06-19 08:49:26 +00:00

650 lines
28 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Resources\EnvironmentReviewResource\Pages;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource;
use App\Models\EnvironmentReview;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ReviewPublicationResolutionCase;
use App\Models\ReviewPublicationResolutionStep;
use App\Models\User;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionActionService;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionCaseStatus;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionService;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepAuthorizer;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepKey;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepStatus;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
use Filament\Resources\Pages\Page;
use Illuminate\Database\Eloquent\Model;
use Livewire\Attributes\Locked;
class ResolveReviewPublication extends Page
{
use InteractsWithRecord;
protected static string $resource = EnvironmentReviewResource::class;
protected string $view = 'filament.resources.environment-review-resource.pages.resolve-review-publication';
#[Locked]
public ?int $resolutionCaseId = null;
public function mount(int|string $record): void
{
$this->record = $this->resolveRecord($record);
$this->authorizeAccess();
$user = auth()->user();
if (! $user instanceof User || ! $this->record instanceof EnvironmentReview) {
abort(403);
}
$caseService = app(ReviewPublicationResolutionService::class);
$case = $user->can('refresh', $this->record)
? $caseService->openOrResume(
review: $this->record,
actor: $user,
sourceSurface: 'environment_review_detail',
)
: $caseService->activeCaseForReview($this->record);
if ($case instanceof ReviewPublicationResolutionCase && ! $user->can('view', $case)) {
abort(404);
}
if (! $user->can('refresh', $this->record) && ! ($case instanceof ReviewPublicationResolutionCase)) {
abort(403);
}
if (! $case instanceof ReviewPublicationResolutionCase) {
Notification::make()
->success()
->title('Review is ready for publication')
->body('No publication resolution case was needed for the current review state.')
->send();
$this->redirect(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $this->record], $this->record->tenant));
return;
}
$this->resolutionCaseId = (int) $case->getKey();
$this->heading = $case->status === ReviewPublicationResolutionCaseStatus::ReadyToContinue->value
? 'Review is ready to continue'
: 'Review can\'t be published yet';
$this->subheading = $this->record->tenant?->getFilamentName();
}
public function getTitle(): string
{
return $this->pageOutcomeTitle();
}
public function getHeading(): string
{
return $this->pageOutcomeTitle();
}
protected function resolveRecord(int|string $key): Model
{
return EnvironmentReviewResource::resolveScopedRecordOrFail($key);
}
protected function getHeaderActions(): array
{
return array_values(array_filter([
$this->executeCurrentStepAction(),
Actions\Action::make('back_to_review')
->label('Return to review')
->icon('heroicon-o-arrow-left')
->color('gray')
->url(fn (): string => $this->reviewUrl()),
Actions\ActionGroup::make([
$this->cancelResolutionAction(),
])
->label('More')
->icon('heroicon-o-ellipsis-vertical')
->color('gray')
->visible(fn (): bool => $this->resolutionCase()?->statusEnum()->isActive() ?? false),
]));
}
/**
* @return array<string, mixed>
*/
public function caseState(): array
{
$case = $this->resolutionCase();
if (! $case instanceof ReviewPublicationResolutionCase) {
return [
'case' => null,
'steps' => [],
];
}
$case->loadMissing(['steps.operationRun', 'environmentReview.tenant', 'tenant']);
$tenant = $case->tenant;
$currentStep = $case->currentStep();
return [
'case' => [
'id' => (int) $case->getKey(),
'status' => (string) $case->status,
'status_label' => $this->statusLabel((string) $case->status),
'status_color' => $this->caseStatusColor((string) $case->status),
'current_step_key' => $case->current_step_key,
'summary' => is_array($case->summary) ? $case->summary : [],
'created_at' => $case->created_at?->diffForHumans(),
'last_evaluated_at' => $case->last_evaluated_at?->diffForHumans(),
],
'decision' => $this->decisionState($case, $currentStep),
'steps' => $case->steps
->map(fn (ReviewPublicationResolutionStep $step): array => $this->stepState($step, $tenant))
->values()
->all(),
];
}
private function executeCurrentStepAction(): Actions\Action
{
$action = Actions\Action::make('execute_current_step')
->label(fn (): string => $this->currentStepActionLabel())
->icon(fn (): string => $this->currentStepActionIcon())
->color('primary')
->visible(fn (): bool => $this->hasExecutableCurrentStep() && ! $this->currentStepIsRunning())
->disabled(fn (): bool => $this->currentStepIsRunning() || ! $this->canExecuteCurrentStep())
->tooltip(fn (): ?string => $this->currentStepActionTooltip())
->requiresConfirmation()
->modalHeading(fn (): string => $this->currentStepConfirmationHeading())
->modalDescription(fn (): string => $this->currentStepConfirmationDescription())
->modalSubmitActionLabel(fn (): string => $this->currentStepActionLabel())
->action(function (): void {
$case = $this->resolutionCase();
$user = auth()->user();
if (! $case instanceof ReviewPublicationResolutionCase || ! $user instanceof User) {
abort(403);
}
if (! $this->canExecuteCurrentStep()) {
abort(403);
}
$result = app(ReviewPublicationResolutionActionService::class)->executeCurrentStep($case, $user);
$updatedCase = $result['case'];
$this->resolutionCaseId = (int) $updatedCase->getKey();
if ($result['operation_run'] instanceof OperationRun && is_string($result['operation_type'])) {
OperationUxPresenter::queuedToast($result['operation_type'])->send();
return;
}
Notification::make()
->success()
->title('Resolution step completed')
->send();
if ($updatedCase->status === ReviewPublicationResolutionCaseStatus::Completed->value) {
$this->redirect($this->reviewUrl());
}
});
return $action;
}
private function cancelResolutionAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('cancel_resolution')
->label('Cancel resolution')
->icon('heroicon-o-x-circle')
->color('danger')
->visible(fn (): bool => $this->resolutionCase()?->statusEnum()->isActive() ?? false)
->requiresConfirmation()
->modalHeading('Cancel publication resolution')
->modalDescription('This only cancels the TenantPilot resolution case. It does not modify the provider tenant or publish the review.')
->action(function (): void {
$case = $this->resolutionCase();
$user = auth()->user();
if (! $case instanceof ReviewPublicationResolutionCase || ! $user instanceof User) {
abort(403);
}
app(ReviewPublicationResolutionService::class)->cancel($case, $user);
Notification::make()
->success()
->title('Resolution cancelled')
->send();
$this->redirect($this->reviewUrl());
}),
)
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
private 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);
}
}
private function resolutionCase(): ?ReviewPublicationResolutionCase
{
if (! is_numeric($this->resolutionCaseId)) {
return null;
}
$case = ReviewPublicationResolutionCase::query()
->with(['steps.operationRun', 'environmentReview.tenant', 'tenant'])
->find((int) $this->resolutionCaseId);
if (! $case instanceof ReviewPublicationResolutionCase) {
return null;
}
$user = auth()->user();
if (! $user instanceof User || ! $user->can('view', $case)) {
abort(403);
}
return $case;
}
private function pageOutcomeTitle(): string
{
$case = $this->resolutionCase();
if ($case instanceof ReviewPublicationResolutionCase
&& $case->status === ReviewPublicationResolutionCaseStatus::ReadyToContinue->value) {
return 'Review is ready to continue';
}
return 'Review can\'t be published yet';
}
private function currentStep(): ?ReviewPublicationResolutionStep
{
return $this->resolutionCase()?->currentStep();
}
private function hasExecutableCurrentStep(): bool
{
$step = $this->currentStep();
if (! $step instanceof ReviewPublicationResolutionStep) {
return false;
}
return $step->statusEnum() !== ReviewPublicationResolutionStepStatus::Pending;
}
private function currentStepIsRunning(): bool
{
return $this->currentStep()?->statusEnum() === ReviewPublicationResolutionStepStatus::Running;
}
private function canExecuteCurrentStep(): bool
{
$case = $this->resolutionCase();
$user = auth()->user();
if (! $case instanceof ReviewPublicationResolutionCase || ! $user instanceof User) {
return false;
}
return app(ReviewPublicationResolutionStepAuthorizer::class)->canExecuteCurrentStep($user, $case);
}
private function currentStepActionTooltip(): ?string
{
if ($this->currentStepIsRunning()) {
return 'The linked operation is still running.';
}
return $this->canExecuteCurrentStep() ? null : AuthUiTooltips::insufficientPermission();
}
private function currentStepActionLabel(): string
{
return $this->currentStepActionLabelFor($this->currentStep()?->stepKeyEnum());
}
private function currentStepActionLabelFor(?ReviewPublicationResolutionStepKey $stepKey): string
{
return match ($stepKey) {
ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Update required reports',
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'Collect evidence',
ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'Refresh review',
ReviewPublicationResolutionStepKey::GenerateReviewPack => 'Prepare export',
ReviewPublicationResolutionStepKey::ReturnToPublication => 'Return to review',
default => 'Continue',
};
}
private function currentStepActionIcon(): string
{
return $this->currentStepActionIconFor($this->currentStep()?->stepKeyEnum());
}
private function currentStepActionIconFor(?ReviewPublicationResolutionStepKey $stepKey): string
{
return match ($stepKey) {
ReviewPublicationResolutionStepKey::CompleteRequiredReports,
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot,
ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'heroicon-o-arrow-path',
ReviewPublicationResolutionStepKey::GenerateReviewPack => 'heroicon-o-arrow-down-tray',
ReviewPublicationResolutionStepKey::ReturnToPublication => 'heroicon-o-check-badge',
default => 'heroicon-o-play',
};
}
private function reviewUrl(): string
{
$record = $this->getRecord();
if (! $record instanceof EnvironmentReview) {
return url('/admin');
}
return EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $record], $record->tenant);
}
/**
* @return array<string, mixed>
*/
private function decisionState(ReviewPublicationResolutionCase $case, ?ReviewPublicationResolutionStep $currentStep): array
{
$summary = is_array($case->summary) ? $case->summary : [];
$missingReports = collect((array) ($summary['missing_report_dimensions'] ?? []))
->map(fn (mixed $dimension): string => $this->reportRequirementLabel((string) $dimension))
->filter(static fn (string $dimension): bool => $dimension !== '')
->values()
->all();
$blockers = collect((array) ($summary['publication_blockers'] ?? []))
->map(fn (mixed $blocker): string => $this->operatorBlockerLabel((string) $blocker))
->filter(static fn (string $blocker): bool => $blocker !== '')
->unique()
->values()
->all();
$blockerCount = max((int) ($summary['publication_blocker_count'] ?? 0), count($blockers), count($missingReports));
$stepKey = $currentStep?->stepKeyEnum();
$stepStatus = $currentStep?->statusEnum();
$stepIsRunning = $stepStatus === ReviewPublicationResolutionStepStatus::Running;
$stepFailed = $stepStatus === ReviewPublicationResolutionStepStatus::Failed;
$readyToReturn = (string) $case->status === ReviewPublicationResolutionCaseStatus::ReadyToContinue->value
|| $stepKey === ReviewPublicationResolutionStepKey::ReturnToPublication;
$permissionNotice = $currentStep instanceof ReviewPublicationResolutionStep
&& $this->hasExecutableCurrentStep()
&& ! $stepIsRunning
&& ! $this->canExecuteCurrentStep()
? 'You can inspect this preparation flow, but you do not have permission to run the next action.'
: null;
$stateNotice = match (true) {
$stepIsRunning => [
'label' => 'Operation in progress',
'color' => 'info',
'body' => 'TenantPilot is waiting for the linked operation to finish. No new start action is available while it runs.',
],
$stepFailed => [
'label' => 'Action needed',
'color' => 'danger',
'body' => 'The last operation did not complete. Review the linked operation, then retry the current preparation action when you are ready.',
],
default => null,
};
return [
'headline' => $readyToReturn ? 'Review is ready to continue' : 'Review can\'t be published yet',
'status_badge_label' => match (true) {
$stepIsRunning => 'Operation in progress',
$stepFailed => 'Action needed',
$readyToReturn => 'Ready to continue',
default => 'Publication blocked',
},
'status_badge_color' => match (true) {
$stepIsRunning => 'info',
$stepFailed => 'danger',
$readyToReturn => 'success',
default => 'warning',
},
'blocked_summary' => $this->blockedSummary($blockerCount, count($missingReports)),
'blockers' => $blockers,
'missing_reports' => $missingReports,
'next_action_label' => $stepIsRunning ? 'Operation in progress' : $this->currentStepActionLabelFor($stepKey),
'next_action_description' => $this->nextStepDescription($stepKey, $stepStatus),
'after_this' => $this->afterNextStepDescription($stepKey),
'state_notice' => $stateNotice,
'permission_notice' => $permissionNotice,
];
}
/**
* @return array<string, mixed>
*/
private function stepState(ReviewPublicationResolutionStep $step, ?ManagedEnvironment $tenant): array
{
$operationRun = $step->operationRun;
$stepKey = $step->stepKeyEnum();
return [
'key' => (string) $step->step_key,
'label' => (string) data_get($step->summary, 'label', $step->step_key),
'operator_label' => $this->operatorStepLabel($stepKey),
'description' => (string) data_get($step->summary, 'description', ''),
'operator_description' => $this->operatorStepDescription($stepKey),
'state_description' => (string) data_get($step->summary, 'state_description', ''),
'status' => (string) $step->status,
'status_label' => $this->statusLabel((string) $step->status),
'status_color' => $this->stepStatusColor((string) $step->status),
'proof_type' => $step->proof_type,
'proof_id' => $step->proof_id,
'proof_status' => $step->proof_status,
'proof_url' => $this->proofUrl($step, $tenant),
'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
'operation_url' => $operationRun instanceof OperationRun && $tenant instanceof ManagedEnvironment
? OperationRunLinks::view($operationRun, $tenant)
: null,
];
}
private function proofUrl(ReviewPublicationResolutionStep $step, ?ManagedEnvironment $tenant): ?string
{
if (! $tenant instanceof ManagedEnvironment || ! is_numeric($step->proof_id) || ! is_string($step->proof_type)) {
return null;
}
return match ($step->proof_type) {
'environment_review' => EnvironmentReviewResource::environmentScopedUrl('view', ['record' => (int) $step->proof_id], $tenant),
'evidence_snapshot' => EvidenceSnapshotResource::getUrl('view', ['record' => (int) $step->proof_id], tenant: $tenant),
'review_pack' => ReviewPackResource::getUrl('view', ['record' => (int) $step->proof_id], tenant: $tenant),
default => null,
};
}
private function statusLabel(string $status): string
{
return str($status)->replace('_', ' ')->title()->toString();
}
private function caseStatusColor(string $status): string
{
return match ($status) {
ReviewPublicationResolutionCaseStatus::Completed->value => 'success',
ReviewPublicationResolutionCaseStatus::Blocked->value => 'danger',
ReviewPublicationResolutionCaseStatus::WaitingForRun->value => 'info',
ReviewPublicationResolutionCaseStatus::ReadyToContinue->value => 'warning',
ReviewPublicationResolutionCaseStatus::Cancelled->value,
ReviewPublicationResolutionCaseStatus::Superseded->value => 'gray',
default => 'primary',
};
}
private function stepStatusColor(string $status): string
{
return match ($status) {
ReviewPublicationResolutionStepStatus::Completed->value => 'success',
ReviewPublicationResolutionStepStatus::Failed->value => 'danger',
ReviewPublicationResolutionStepStatus::Running->value => 'info',
ReviewPublicationResolutionStepStatus::Actionable->value => 'warning',
ReviewPublicationResolutionStepStatus::Superseded->value => 'gray',
default => 'gray',
};
}
private function reportRequirementLabel(string $dimension): string
{
return match ($dimension) {
'permission_posture' => 'Permission posture',
'entra_admin_roles' => 'Entra admin roles',
default => str($dimension)->replace('_', ' ')->title()->toString(),
};
}
private function nextStepDescription(
?ReviewPublicationResolutionStepKey $stepKey,
?ReviewPublicationResolutionStepStatus $stepStatus = null,
): string {
if ($stepStatus === ReviewPublicationResolutionStepStatus::Running) {
return 'TenantPilot is waiting for the linked operation to finish. No new start action is available while it runs.';
}
if ($stepStatus === ReviewPublicationResolutionStepStatus::Failed) {
return 'The last operation did not complete. Review the linked operation, then retry the current preparation action when you are ready.';
}
return match ($stepKey) {
ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'TenantPilot will update the missing required reports. It will not publish the review.',
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'This will collect a current evidence snapshot for the review. It will not publish the review.',
ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'This will refresh the review from current evidence. It will not publish the review.',
ReviewPublicationResolutionStepKey::GenerateReviewPack => 'This will prepare the customer-ready export package. It will not publish the review.',
ReviewPublicationResolutionStepKey::ReturnToPublication => 'All checks are resolved. Return to the review and use the existing publish action when you are ready.',
default => 'TenantPilot will continue the next safe preparation step. It will not publish the review.',
};
}
private function afterNextStepDescription(?ReviewPublicationResolutionStepKey $stepKey): string
{
return match ($stepKey) {
ReviewPublicationResolutionStepKey::ReturnToPublication => 'Publishing remains a separate action on the review page.',
default => 'After this, TenantPilot re-checks the evidence, refreshes the review if needed, prepares the export if needed, and sends you back to the review. Publishing remains a separate action.',
};
}
private function currentStepConfirmationDescription(): string
{
return match ($this->currentStep()?->stepKeyEnum()) {
ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'TenantPilot will update the missing required reports. This will not publish the review.',
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'TenantPilot will collect a current evidence snapshot for this review. This will not publish the review.',
ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'TenantPilot will refresh the review from current evidence. This will not publish the review.',
ReviewPublicationResolutionStepKey::GenerateReviewPack => 'TenantPilot will prepare the customer-ready export package for this review. This will not publish the review.',
ReviewPublicationResolutionStepKey::ReturnToPublication => 'TenantPilot will return you to the review. Publishing remains a separate action.',
default => 'TenantPilot will continue the next safe preparation step. This will not publish the review.',
};
}
private function currentStepConfirmationHeading(): string
{
return $this->currentStepActionLabel().'?';
}
private function blockedSummary(int $blockerCount, int $missingReportCount): string
{
if ($missingReportCount > 0) {
return sprintf(
'TenantPilot found %d required %s that must be updated before this review can become customer-ready.',
$missingReportCount,
$missingReportCount === 1 ? 'report' : 'reports',
);
}
if ($blockerCount > 0) {
return sprintf(
'TenantPilot found %d missing %s before this review can become customer-ready.',
$blockerCount,
$blockerCount === 1 ? 'requirement' : 'requirements',
);
}
return 'TenantPilot is checking the remaining publication prerequisites before this review returns to the publish action.';
}
private function operatorBlockerLabel(string $blocker): string
{
$blocker = trim($blocker);
if ($blocker === '') {
return '';
}
if (str($blocker)->lower()->contains('report-backed evidence')) {
return 'Required reports are missing.';
}
return $blocker;
}
private function operatorStepLabel(?ReviewPublicationResolutionStepKey $stepKey): string
{
return match ($stepKey) {
ReviewPublicationResolutionStepKey::ValidateReviewReadiness => 'Check readiness',
ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Update required reports',
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'Collect evidence',
ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'Refresh review',
ReviewPublicationResolutionStepKey::GenerateReviewPack => 'Prepare export',
ReviewPublicationResolutionStepKey::ReturnToPublication => 'Return to review',
default => 'Continue preparation',
};
}
private function operatorStepDescription(?ReviewPublicationResolutionStepKey $stepKey): string
{
return match ($stepKey) {
ReviewPublicationResolutionStepKey::ValidateReviewReadiness => 'TenantPilot checks whether the review is safe to prepare for publication.',
ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Required reports must be current before publication can continue.',
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'The review needs a complete and current evidence snapshot.',
ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'The review is rebuilt from the latest evidence and report state.',
ReviewPublicationResolutionStepKey::GenerateReviewPack => 'The customer-ready export is prepared before returning to the review.',
ReviewPublicationResolutionStepKey::ReturnToPublication => 'Return to the review and decide whether to publish.',
default => 'TenantPilot prepares the next safe publication prerequisite.',
};
}
}