Automated PR created by Codex via Gitea API. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #460
822 lines
32 KiB
PHP
822 lines
32 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\GovernanceInbox;
|
|
|
|
use App\Filament\Resources\EnvironmentReviewResource;
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\ReviewPublicationResolutionCase;
|
|
use App\Models\ReviewPublicationResolutionStep;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Support\Navigation\CanonicalNavigationContext;
|
|
use App\Support\OperationCatalog;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\ReviewPublicationResolution\ResolutionProofCurrentness;
|
|
use App\Support\ReviewPublicationResolution\ResolutionProofEvaluation;
|
|
use App\Support\ReviewPublicationResolution\ResolutionProofUsability;
|
|
use App\Support\ReviewPublicationResolution\ResolutionProofVisibility;
|
|
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionCaseStatus;
|
|
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepAuthorizer;
|
|
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepKey;
|
|
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepStatus;
|
|
use Illuminate\Support\Facades\Gate;
|
|
|
|
final readonly class ReviewPublicationResolutionInboxProvider
|
|
{
|
|
public const string FAMILY_KEY = 'review_publication_resolution';
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
public const array STATUS_FILTERS = [
|
|
'needs_attention',
|
|
'needs_recheck',
|
|
'waiting',
|
|
'ready_to_continue',
|
|
'failed',
|
|
'blocked',
|
|
];
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
public const array UPDATED_FILTERS = [
|
|
'last_24_hours',
|
|
'last_7_days',
|
|
'last_30_days',
|
|
];
|
|
|
|
public function __construct(
|
|
private ReviewPublicationResolutionStepAuthorizer $stepAuthorizer,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $reviewTenants
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<int, ManagedEnvironment> $reviewTenants
|
|
* @return list<int>
|
|
*/
|
|
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<string, mixed>|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<string>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, int> $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<int, array<string, mixed>> $entries
|
|
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
|
|
*/
|
|
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<string, mixed> $entry
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function withoutInternalSortKeys(array $entry): array
|
|
{
|
|
unset($entry['updated_sort']);
|
|
|
|
return $entry;
|
|
}
|
|
}
|