*/ public const array STATUS_FILTERS = [ 'needs_attention', 'needs_recheck', 'waiting', 'ready_to_continue', 'failed', 'blocked', ]; /** * @var list */ public const array UPDATED_FILTERS = [ 'last_24_hours', 'last_7_days', 'last_30_days', ]; public function __construct( private ReviewPublicationResolutionStepAuthorizer $stepAuthorizer, ) {} /** * @param array $reviewTenants * @return array */ public function section( User $user, Workspace $workspace, array $reviewTenants, ?ManagedEnvironment $selectedTenant, ?string $selectedStatus, ?string $selectedUpdated, ?CanonicalNavigationContext $navigationContext, int $previewLimit, ): array { $tenantIds = $this->scopedTenantIds($reviewTenants, $selectedTenant); if ($tenantIds === []) { return $this->emptySection($selectedTenant, $selectedStatus, $selectedUpdated); } $query = ReviewPublicationResolutionCase::query() ->forWorkspace((int) $workspace->getKey()) ->active() ->whereIn('managed_environment_id', $tenantIds); if (in_array($selectedUpdated, self::UPDATED_FILTERS, true)) { $query->where('updated_at', '>=', $this->updatedSince($selectedUpdated)); } $previewEntries = collect(); $count = 0; $statusCounts = []; $shouldFilterStatus = in_array($selectedStatus, self::STATUS_FILTERS, true); (clone $query) ->with([ 'tenant', 'environmentReview.tenant', 'steps.operationRun', 'assignee', 'creator', ]) ->chunkById(100, function ($cases) use ( $user, $navigationContext, $selectedStatus, $shouldFilterStatus, $previewLimit, &$previewEntries, &$count, &$statusCounts, ): void { foreach ($cases as $case) { if (! $case instanceof ReviewPublicationResolutionCase || ! Gate::forUser($user)->allows('view', $case)) { continue; } $entry = $this->entry($case, $user, $navigationContext); if (! is_array($entry)) { continue; } if ($shouldFilterStatus && ($entry['inbox_status'] ?? null) !== $selectedStatus) { continue; } $count++; $status = (string) ($entry['inbox_status'] ?? 'needs_recheck'); $statusCounts[$status] = (int) ($statusCounts[$status] ?? 0) + 1; $previewEntries->push($entry); $previewEntries = $this->sortEntries($previewEntries) ->take($previewLimit) ->values(); } }); return [ 'key' => self::FAMILY_KEY, 'label' => 'Review publication work', 'count' => $count, 'summary' => $this->summary($count, $statusCounts, $selectedStatus, $selectedUpdated), 'dominant_action_label' => 'Review publication work', 'dominant_action_url' => null, 'entries' => $previewEntries ->map(fn (array $entry): array => $this->withoutInternalSortKeys($entry)) ->values() ->all(), 'empty_state' => $this->emptyState($selectedTenant, $selectedStatus, $selectedUpdated), ]; } public static function statusLabel(string $status): string { return match ($status) { 'needs_attention' => 'Needs attention', 'needs_recheck' => 'Needs re-check', 'waiting' => 'Waiting', 'ready_to_continue' => 'Ready to continue', 'failed' => 'Failed', 'blocked' => 'Blocked', default => 'Needs attention', }; } public static function updatedLabel(?string $updated): string { return match ($updated) { 'last_24_hours' => 'Last 24 hours', 'last_7_days' => 'Last 7 days', 'last_30_days' => 'Last 30 days', default => 'Any time', }; } /** * @param array $reviewTenants * @return list */ private function scopedTenantIds(array $reviewTenants, ?ManagedEnvironment $selectedTenant): array { if ($selectedTenant instanceof ManagedEnvironment) { return array_key_exists((int) $selectedTenant->getKey(), $reviewTenants) ? [(int) $selectedTenant->getKey()] : []; } return array_map( static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(), $reviewTenants, ); } /** * @return array|null */ private function entry( ReviewPublicationResolutionCase $case, User $user, ?CanonicalNavigationContext $navigationContext, ): ?array { $tenant = $case->tenant; $review = $case->environmentReview; if (! $tenant instanceof ManagedEnvironment || ! $review instanceof EnvironmentReview) { return null; } $currentStep = $case->currentStep(); $canExecute = $this->stepAuthorizer->canExecuteCurrentStep($user, $case); $operationAction = $this->operationAction($case, $currentStep, $user, $tenant, $navigationContext); $inboxStatus = $this->inboxStatus($case, $currentStep, $canExecute, $operationAction !== null); $primaryAction = $this->primaryAction($inboxStatus, $operationAction); $resolutionUrl = EnvironmentReviewResource::environmentScopedUrl( 'resolve-publication', ['record' => $review], $tenant, ); $reviewUrl = EnvironmentReviewResource::environmentScopedUrl( 'view', ['record' => $review], $tenant, ); return [ 'family_key' => self::FAMILY_KEY, 'source_model' => ReviewPublicationResolutionCase::class, 'source_key' => (string) $case->getKey(), 'managed_environment_id' => (int) $tenant->getKey(), 'tenant_label' => $tenant->name, 'headline' => $this->headline($inboxStatus), 'subline' => $this->subline($case, $review, $currentStep), 'urgency_rank' => $this->urgencyRank($inboxStatus), 'status_label' => self::statusLabel($inboxStatus), 'inbox_status' => $inboxStatus, 'destination_url' => $resolutionUrl, 'reason_label' => $this->reasonLabel($inboxStatus, $currentStep, $canExecute), 'impact_label' => $this->impactLabel($inboxStatus), 'owner_label' => $case->assignee?->name ?? $case->creator?->name ?? 'Owner unavailable', 'due_label' => 'No due date set', 'evidence_label' => $this->evidenceLabel($currentStep), 'exception_label' => 'Publication preparation', 'primary_action_label' => $primaryAction['label'], 'primary_action_url' => $primaryAction['url'] ?? $resolutionUrl, 'secondary_actions' => array_values(array_filter([ [ 'label' => 'Open review', 'url' => $reviewUrl, ], ($operationAction['url'] ?? null) !== ($primaryAction['url'] ?? null) ? $operationAction : null, ])), 'linked_records' => array_values(array_filter([ [ 'label' => 'Resolution preparation', 'url' => $resolutionUrl, ], [ 'label' => 'Review', 'url' => $reviewUrl, ], $operationAction, ])), 'updated_sort' => $case->updated_at?->getTimestamp() ?? 0, 'back_label' => 'Back to governance inbox', ]; } /** * @return array{label: string, url: string}|null */ private function operationAction( ReviewPublicationResolutionCase $case, ?ReviewPublicationResolutionStep $currentStep, User $user, ManagedEnvironment $tenant, ?CanonicalNavigationContext $navigationContext, ): ?array { if (! $this->canDiscloseOperationRun($case, $currentStep, $user)) { return null; } /** @var OperationRun $operationRun */ $operationRun = $currentStep->operationRun; return [ 'label' => OperationRunLinks::openLabel(), 'url' => OperationRunLinks::view($operationRun, $tenant, $navigationContext), ]; } private function canDiscloseOperationRun( ReviewPublicationResolutionCase $case, ?ReviewPublicationResolutionStep $currentStep, User $user, ): bool { if (! $currentStep instanceof ReviewPublicationResolutionStep) { return false; } $operationRun = $currentStep->operationRun; $stepKey = $currentStep->stepKeyEnum(); if (! $operationRun instanceof OperationRun || ! $stepKey instanceof ReviewPublicationResolutionStepKey) { return false; } if (! is_numeric($currentStep->operation_run_id) || (int) $currentStep->operation_run_id !== (int) $operationRun->getKey()) { return false; } if ((int) $operationRun->workspace_id !== (int) $case->workspace_id || (int) $operationRun->managed_environment_id !== (int) $case->managed_environment_id) { return false; } if (! Gate::forUser($user)->allows('view', $operationRun)) { return false; } if (! $this->operationTypeMatchesStep($operationRun, $stepKey)) { return false; } if (! $this->operationStateMatchesStep($operationRun, $currentStep->statusEnum())) { return false; } if (! $this->operationContextMatchesCase($operationRun, $case)) { return false; } if (! $this->safeCurrentProofMetadata($currentStep)) { return false; } return $this->proofMatchesReview($case, $currentStep, $operationRun); } private function operationTypeMatchesStep(OperationRun $operationRun, ReviewPublicationResolutionStepKey $stepKey): bool { $actualType = OperationCatalog::canonicalCode((string) $operationRun->type); $expectedTypes = array_map( static fn (string $type): string => OperationCatalog::canonicalCode($type), $this->expectedOperationTypes($stepKey), ); return in_array($actualType, $expectedTypes, true); } private function operationStateMatchesStep(OperationRun $operationRun, ReviewPublicationResolutionStepStatus $stepStatus): bool { if ($stepStatus === ReviewPublicationResolutionStepStatus::Running) { return in_array((string) $operationRun->status, [ OperationRunStatus::Queued->value, OperationRunStatus::Running->value, ], true) && (string) $operationRun->outcome === OperationRunOutcome::Pending->value; } if ($stepStatus === ReviewPublicationResolutionStepStatus::Failed) { return (string) $operationRun->status === OperationRunStatus::Completed->value && in_array((string) $operationRun->outcome, [ OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value, ], true); } return false; } private function operationContextMatchesCase(OperationRun $operationRun, ReviewPublicationResolutionCase $case): bool { $context = is_array($operationRun->context) ? $operationRun->context : []; foreach ([ 'workspace_id' => (int) $case->workspace_id, 'managed_environment_id' => (int) $case->managed_environment_id, 'review_publication_resolution_case_id' => (int) $case->getKey(), ] as $key => $expectedValue) { $value = $context[$key] ?? null; if (! is_numeric($value) || (int) $value !== $expectedValue) { return false; } } $reviewIds = collect([ $context['environment_review_id'] ?? null, $context['review_id'] ?? null, ]) ->filter(fn (mixed $value): bool => is_numeric($value)) ->map(fn (mixed $value): int => (int) $value) ->unique() ->values(); if ($reviewIds->count() !== 1 || $reviewIds->first() !== (int) $case->environment_review_id) { return false; } return ($context['trigger'] ?? null) === 'review_publication_resolution'; } private function safeCurrentProofMetadata(ReviewPublicationResolutionStep $step): bool { if ((string) data_get($step->metadata, 'proof_currentness') !== ResolutionProofCurrentness::Current->value) { return false; } if ((string) data_get($step->metadata, 'proof_visibility') !== ResolutionProofVisibility::OperatorVisible->value) { return false; } if (! in_array((string) data_get($step->metadata, 'proof_usability'), [ ResolutionProofUsability::Usable->value, ResolutionProofUsability::UsableWithWarning->value, ResolutionProofUsability::InspectionOnly->value, ], true)) { return false; } $summary = data_get($step->metadata, 'proof_summary'); if (! is_array($summary)) { return false; } return ResolutionProofEvaluation::sanitizeSummary($summary) === $summary; } private function proofMatchesReview( ReviewPublicationResolutionCase $case, ReviewPublicationResolutionStep $step, OperationRun $operationRun, ): bool { $stepKey = $step->stepKeyEnum(); if (! $stepKey instanceof ReviewPublicationResolutionStepKey || ! is_string($step->proof_type) || ! is_numeric($step->proof_id)) { return false; } if ($stepKey === ReviewPublicationResolutionStepKey::CompleteRequiredReports) { return $step->proof_type === 'operation_run' && (int) $step->proof_id === (int) $operationRun->getKey(); } return match ($stepKey) { ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => $step->proof_type === 'evidence_snapshot' && EvidenceSnapshot::query() ->whereKey((int) $step->proof_id) ->where('workspace_id', (int) $case->workspace_id) ->where('managed_environment_id', (int) $case->managed_environment_id) ->where('operation_run_id', (int) $operationRun->getKey()) ->exists(), ReviewPublicationResolutionStepKey::RefreshReviewComposition => $step->proof_type === 'environment_review' && (int) $step->proof_id === (int) $case->environment_review_id && EnvironmentReview::query() ->whereKey((int) $case->environment_review_id) ->where('workspace_id', (int) $case->workspace_id) ->where('managed_environment_id', (int) $case->managed_environment_id) ->where('operation_run_id', (int) $operationRun->getKey()) ->exists(), ReviewPublicationResolutionStepKey::GenerateReviewPack => $step->proof_type === 'review_pack' && ReviewPack::query() ->whereKey((int) $step->proof_id) ->where('workspace_id', (int) $case->workspace_id) ->where('managed_environment_id', (int) $case->managed_environment_id) ->where('environment_review_id', (int) $case->environment_review_id) ->where('operation_run_id', (int) $operationRun->getKey()) ->exists(), default => false, }; } /** * @return list */ private function expectedOperationTypes(ReviewPublicationResolutionStepKey $stepKey): array { return match ($stepKey) { ReviewPublicationResolutionStepKey::CompleteRequiredReports => [ 'provider.connection.check', OperationRunType::EntraAdminRolesScan->value, ], ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => [ OperationRunType::EvidenceSnapshotGenerate->value, ], ReviewPublicationResolutionStepKey::RefreshReviewComposition => [ OperationRunType::EnvironmentReviewCompose->value, ], ReviewPublicationResolutionStepKey::GenerateReviewPack => [ OperationRunType::ReviewPackGenerate->value, ], ReviewPublicationResolutionStepKey::ValidateReviewReadiness, ReviewPublicationResolutionStepKey::ReturnToPublication => [], }; } /** * @return array */ private function emptySection( ?ManagedEnvironment $selectedTenant, ?string $selectedStatus, ?string $selectedUpdated, ): array { return [ 'key' => self::FAMILY_KEY, 'label' => 'Review publication work', 'count' => 0, 'summary' => $this->summary(0, [], $selectedStatus, $selectedUpdated), 'dominant_action_label' => 'Review publication work', 'dominant_action_url' => null, 'entries' => [], 'empty_state' => $this->emptyState($selectedTenant, $selectedStatus, $selectedUpdated), ]; } /** * @param array $statusCounts */ private function summary(int $count, array $statusCounts, ?string $selectedStatus, ?string $selectedUpdated): string { if ($count === 0) { return $this->filterSummaryPrefix($selectedStatus, $selectedUpdated).'No active review publication preparation is visible.'; } $attentionCount = (int) ($statusCounts['needs_attention'] ?? 0); $waitingCount = (int) ($statusCounts['waiting'] ?? 0); $blockedCount = (int) ($statusCounts['failed'] ?? 0) + (int) ($statusCounts['blocked'] ?? 0); return sprintf( '%s%d active review publication preparation %s visible; %d need attention, %d waiting, %d failed or blocked.', $this->filterSummaryPrefix($selectedStatus, $selectedUpdated), $count, $count === 1 ? 'item is' : 'items are', $attentionCount, $waitingCount, $blockedCount, ); } private function filterSummaryPrefix(?string $selectedStatus, ?string $selectedUpdated): string { $parts = []; if (in_array($selectedStatus, self::STATUS_FILTERS, true)) { $parts[] = self::statusLabel($selectedStatus); } if (in_array($selectedUpdated, self::UPDATED_FILTERS, true)) { $parts[] = self::updatedLabel($selectedUpdated); } return $parts === [] ? '' : implode(' / ', $parts).': '; } private function emptyState(?ManagedEnvironment $selectedTenant, ?string $selectedStatus, ?string $selectedUpdated): string { if (in_array($selectedStatus, self::STATUS_FILTERS, true) || in_array($selectedUpdated, self::UPDATED_FILTERS, true)) { return 'No review publication preparation work matches these filters right now.'; } if ($selectedTenant instanceof ManagedEnvironment) { return 'No review publication preparation work matches this environment filter right now.'; } return 'No active review publication preparation work needs attention right now.'; } private function headline(string $status): string { return match ($status) { 'waiting' => 'Review preparation is running', 'ready_to_continue' => 'Review preparation can continue', 'failed' => 'Review preparation action failed', 'blocked' => 'Review preparation needs operator access', 'needs_recheck' => 'Review preparation needs re-check', default => 'Review cannot be published yet', }; } private function subline( ReviewPublicationResolutionCase $case, EnvironmentReview $review, ?ReviewPublicationResolutionStep $currentStep, ): string { $stepLabel = $this->stepLabel($currentStep?->stepKeyEnum()); $generatedAt = $review->generated_at?->format('M j, Y H:i') ?? 'date unavailable'; return sprintf( '%s · Review generated %s · Case updated %s', $stepLabel, $generatedAt, $case->updated_at?->diffForHumans() ?? 'recently', ); } private function inboxStatus( ReviewPublicationResolutionCase $case, ?ReviewPublicationResolutionStep $currentStep, bool $canExecute, bool $hasValidatedOperation, ): string { $caseStatus = $case->statusEnum(); $stepStatus = $currentStep?->statusEnum(); $stepKey = $currentStep?->stepKeyEnum(); if (! $currentStep instanceof ReviewPublicationResolutionStep || ! $stepStatus instanceof ReviewPublicationResolutionStepStatus) { return 'needs_recheck'; } if ($stepStatus === ReviewPublicationResolutionStepStatus::Running) { return $hasValidatedOperation ? 'waiting' : 'needs_recheck'; } if ($stepStatus === ReviewPublicationResolutionStepStatus::Failed) { return $hasValidatedOperation ? 'failed' : 'needs_recheck'; } if ($caseStatus === ReviewPublicationResolutionCaseStatus::ReadyToContinue || $stepKey === ReviewPublicationResolutionStepKey::ReturnToPublication) { if ($stepKey !== ReviewPublicationResolutionStepKey::ReturnToPublication || ! $this->safeReadyToContinueProofMetadata($case, $currentStep)) { return 'needs_recheck'; } return $canExecute ? 'ready_to_continue' : 'blocked'; } if ($stepStatus === ReviewPublicationResolutionStepStatus::Actionable || $stepStatus === ReviewPublicationResolutionStepStatus::Pending) { return $canExecute ? 'needs_attention' : 'blocked'; } if ($caseStatus === ReviewPublicationResolutionCaseStatus::Blocked) { return 'blocked'; } return 'needs_recheck'; } private function reasonLabel(string $status, ?ReviewPublicationResolutionStep $currentStep, bool $canExecute): string { if ($status === 'failed') { return 'The linked preparation operation failed and needs inspection before retry.'; } if ($status === 'waiting') { return 'TenantPilot is waiting for the linked preparation operation to finish.'; } if ($status === 'ready_to_continue') { return 'All preparation checks are resolved and the review can continue from the existing workflow.'; } if ($status === 'blocked') { return $canExecute ? 'The preparation case is blocked and needs inspection.' : 'You can inspect this preparation flow, but you cannot run the next action.'; } if ($status === 'needs_recheck') { return 'Current proof is missing, stale, or not safe enough to determine the next action from the inbox.'; } return match ($currentStep?->stepKeyEnum()) { ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Required reports are missing.', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'A current evidence snapshot is required.', ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'The review must be refreshed from current evidence.', ReviewPublicationResolutionStepKey::GenerateReviewPack => 'The customer-ready export must be prepared.', ReviewPublicationResolutionStepKey::ReturnToPublication => 'The review is ready to return to publication.', default => 'Publication preparation needs an operator decision.', }; } private function impactLabel(string $status): string { return match ($status) { 'waiting' => 'No duplicate start action is exposed while preparation is already running.', 'ready_to_continue' => 'Publishing stays on the review page after the preparation flow returns there.', 'failed' => 'Publication remains blocked until the failed preparation operation is inspected and retried.', 'blocked' => 'Publication remains blocked until an authorized operator continues the preparation flow.', 'needs_recheck' => 'The source preparation page must refresh state before the inbox can classify the next action.', default => 'Publication remains blocked until this preparation step is completed.', }; } private function evidenceLabel(?ReviewPublicationResolutionStep $currentStep): string { if (! $currentStep instanceof ReviewPublicationResolutionStep) { return 'Proof needs re-check'; } return match ($currentStep->stepKeyEnum()) { ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Required reports', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'Evidence snapshot', ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'Review composition', ReviewPublicationResolutionStepKey::GenerateReviewPack => 'Review export', ReviewPublicationResolutionStepKey::ReturnToPublication => 'Publication readiness', default => 'Readiness proof', }; } private function stepLabel(?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 => 'Preparation state', }; } private function urgencyRank(string $status): int { return match ($status) { 'failed' => 0, 'blocked' => 1, 'needs_attention' => 2, 'needs_recheck' => 3, 'ready_to_continue' => 4, 'waiting' => 5, default => 99, }; } /** * @param \Illuminate\Support\Collection> $entries * @return \Illuminate\Support\Collection> */ private function sortEntries(\Illuminate\Support\Collection $entries): \Illuminate\Support\Collection { return $entries ->sortBy([ fn (array $first, array $second): int => (int) ($first['urgency_rank'] ?? 999) <=> (int) ($second['urgency_rank'] ?? 999), fn (array $first, array $second): int => (int) ($second['updated_sort'] ?? 0) <=> (int) ($first['updated_sort'] ?? 0), ]) ->values(); } /** * @return array{label: string, url: string|null} */ private function primaryAction(string $inboxStatus, ?array $operationAction): array { if ($inboxStatus === 'waiting' && is_array($operationAction) && is_string($operationAction['url'] ?? null)) { return [ 'label' => 'Open operation', 'url' => $operationAction['url'], ]; } return [ 'label' => in_array($inboxStatus, ['needs_attention', 'ready_to_continue'], true) ? 'Continue preparation' : 'Inspect preparation', 'url' => null, ]; } private function safeReadyToContinueProofMetadata( ReviewPublicationResolutionCase $case, ReviewPublicationResolutionStep $step, ): bool { if ($step->proof_type !== 'environment_review' || ! is_numeric($step->proof_id) || (int) $step->proof_id !== (int) $case->environment_review_id) { return false; } if ((string) data_get($step->metadata, 'proof_currentness') !== ResolutionProofCurrentness::Current->value) { return false; } if ((string) data_get($step->metadata, 'proof_visibility') !== ResolutionProofVisibility::OperatorVisible->value) { return false; } if (! in_array((string) data_get($step->metadata, 'proof_usability'), [ ResolutionProofUsability::Usable->value, ResolutionProofUsability::UsableWithWarning->value, ], true)) { return false; } $summary = data_get($step->metadata, 'proof_summary'); if (! is_array($summary)) { return false; } return ResolutionProofEvaluation::sanitizeSummary($summary) === $summary; } private function updatedSince(string $selectedUpdated): \Illuminate\Support\Carbon { return match ($selectedUpdated) { 'last_24_hours' => now()->subDay(), 'last_7_days' => now()->subDays(7), 'last_30_days' => now()->subDays(30), default => now()->subYears(50), }; } /** * @param array $entry * @return array */ private function withoutInternalSortKeys(array $entry): array { unset($entry['updated_sort']); return $entry; } }