1344 lines
57 KiB
PHP
1344 lines
57 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\GovernanceInbox;
|
|
|
|
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
|
use App\Filament\Pages\Findings\MyFindingsInbox;
|
|
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
use App\Filament\Resources\AlertDeliveryResource;
|
|
use App\Filament\Resources\EnvironmentReviewResource;
|
|
use App\Filament\Resources\FindingExceptionResource;
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Models\AlertDelivery;
|
|
use App\Models\Finding;
|
|
use App\Models\FindingException;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\ManagedEnvironmentTriageReview;
|
|
use App\Models\OperationRun;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\EnvironmentReviews\EnvironmentReviewRegisterService;
|
|
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
|
use App\Support\Navigation\CanonicalNavigationContext;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\PortfolioTriage\ManagedEnvironmentTriageReviewStateResolver;
|
|
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
|
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
|
use Illuminate\Support\Str;
|
|
|
|
final readonly class GovernanceInboxSectionBuilder
|
|
{
|
|
private const PREVIEW_LIMIT = 3;
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const FAMILY_ORDER = [
|
|
'assigned_findings',
|
|
'intake_findings',
|
|
'finding_exceptions',
|
|
'stale_operations',
|
|
'alert_delivery_failures',
|
|
'review_follow_up',
|
|
];
|
|
|
|
public function __construct(
|
|
private TenantBackupHealthResolver $backupHealthResolver,
|
|
private RestoreSafetyResolver $restoreSafetyResolver,
|
|
private ManagedEnvironmentTriageReviewStateResolver $managedEnvironmentTriageReviewStateResolver,
|
|
private EnvironmentReviewRegisterService $environmentReviewRegisterService,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $authorizedTenants
|
|
* @param array<int, ManagedEnvironment> $visibleFindingTenants
|
|
* @param array<int, ManagedEnvironment> $reviewTenants
|
|
* @return array{
|
|
* sections: list<array<string, mixed>>,
|
|
* available_families: list<array{key: string, label: string, count: int}>,
|
|
* family_counts: array<string, int>,
|
|
* total_count: int,
|
|
* }
|
|
*/
|
|
public function build(
|
|
User $user,
|
|
Workspace $workspace,
|
|
array $authorizedTenants,
|
|
array $visibleFindingTenants,
|
|
array $reviewTenants,
|
|
bool $canViewAlerts,
|
|
bool $canViewFindingExceptions = false,
|
|
?ManagedEnvironment $selectedTenant = null,
|
|
?string $selectedFamily = null,
|
|
?CanonicalNavigationContext $navigationContext = null,
|
|
): array {
|
|
$authorizedTenantsById = $this->indexTenants($authorizedTenants);
|
|
$visibleFindingTenantsById = $this->indexTenants($visibleFindingTenants);
|
|
$reviewTenantsById = $this->indexTenants($reviewTenants);
|
|
|
|
$allSections = [];
|
|
$availableFamilies = [];
|
|
$familyCounts = [];
|
|
|
|
if ($visibleFindingTenantsById !== []) {
|
|
$assignedSection = $this->assignedFindingsSection(
|
|
user: $user,
|
|
visibleFindingTenants: $visibleFindingTenantsById,
|
|
selectedTenant: $selectedTenant,
|
|
navigationContext: $navigationContext,
|
|
);
|
|
$allSections[$assignedSection['key']] = $assignedSection;
|
|
$availableFamilies[] = [
|
|
'key' => $assignedSection['key'],
|
|
'label' => $assignedSection['label'],
|
|
'count' => $assignedSection['count'],
|
|
];
|
|
$familyCounts[$assignedSection['key']] = $assignedSection['count'];
|
|
|
|
$intakeSection = $this->intakeFindingsSection(
|
|
visibleFindingTenants: $visibleFindingTenantsById,
|
|
selectedTenant: $selectedTenant,
|
|
navigationContext: $navigationContext,
|
|
);
|
|
$allSections[$intakeSection['key']] = $intakeSection;
|
|
$availableFamilies[] = [
|
|
'key' => $intakeSection['key'],
|
|
'label' => $intakeSection['label'],
|
|
'count' => $intakeSection['count'],
|
|
];
|
|
$familyCounts[$intakeSection['key']] = $intakeSection['count'];
|
|
}
|
|
|
|
if ($authorizedTenantsById !== []) {
|
|
if ($canViewFindingExceptions) {
|
|
$findingExceptionsSection = $this->findingExceptionsSection(
|
|
workspace: $workspace,
|
|
authorizedTenants: $authorizedTenantsById,
|
|
selectedTenant: $selectedTenant,
|
|
navigationContext: $navigationContext,
|
|
);
|
|
$allSections[$findingExceptionsSection['key']] = $findingExceptionsSection;
|
|
$availableFamilies[] = [
|
|
'key' => $findingExceptionsSection['key'],
|
|
'label' => $findingExceptionsSection['label'],
|
|
'count' => $findingExceptionsSection['count'],
|
|
];
|
|
$familyCounts[$findingExceptionsSection['key']] = $findingExceptionsSection['count'];
|
|
}
|
|
|
|
$operationsSection = $this->operationsSection(
|
|
workspace: $workspace,
|
|
authorizedTenants: $authorizedTenantsById,
|
|
selectedTenant: $selectedTenant,
|
|
navigationContext: $navigationContext,
|
|
);
|
|
$allSections[$operationsSection['key']] = $operationsSection;
|
|
$availableFamilies[] = [
|
|
'key' => $operationsSection['key'],
|
|
'label' => $operationsSection['label'],
|
|
'count' => $operationsSection['count'],
|
|
];
|
|
$familyCounts[$operationsSection['key']] = $operationsSection['count'];
|
|
}
|
|
|
|
if ($canViewAlerts) {
|
|
$alertsSection = $this->alertsSection(
|
|
workspace: $workspace,
|
|
authorizedTenants: $authorizedTenantsById,
|
|
selectedTenant: $selectedTenant,
|
|
navigationContext: $navigationContext,
|
|
);
|
|
$allSections[$alertsSection['key']] = $alertsSection;
|
|
$availableFamilies[] = [
|
|
'key' => $alertsSection['key'],
|
|
'label' => $alertsSection['label'],
|
|
'count' => $alertsSection['count'],
|
|
];
|
|
$familyCounts[$alertsSection['key']] = $alertsSection['count'];
|
|
}
|
|
|
|
if ($reviewTenantsById !== []) {
|
|
$reviewSection = $this->reviewFollowUpSection(
|
|
user: $user,
|
|
workspace: $workspace,
|
|
reviewTenants: $reviewTenantsById,
|
|
selectedTenant: $selectedTenant,
|
|
navigationContext: $navigationContext,
|
|
);
|
|
$allSections[$reviewSection['key']] = $reviewSection;
|
|
$availableFamilies[] = [
|
|
'key' => $reviewSection['key'],
|
|
'label' => $reviewSection['label'],
|
|
'count' => $reviewSection['count'],
|
|
];
|
|
$familyCounts[$reviewSection['key']] = $reviewSection['count'];
|
|
}
|
|
|
|
$sections = [];
|
|
|
|
foreach (self::FAMILY_ORDER as $familyKey) {
|
|
$section = $allSections[$familyKey] ?? null;
|
|
|
|
if (! is_array($section)) {
|
|
continue;
|
|
}
|
|
|
|
if ($selectedFamily !== null) {
|
|
if ($familyKey === $selectedFamily) {
|
|
$sections[] = $section;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if ((int) ($section['count'] ?? 0) > 0) {
|
|
$sections[] = $section;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'sections' => $sections,
|
|
'available_families' => $availableFamilies,
|
|
'family_counts' => $familyCounts,
|
|
'total_count' => array_sum($familyCounts),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $authorizedTenants
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function findingExceptionsSection(
|
|
Workspace $workspace,
|
|
array $authorizedTenants,
|
|
?ManagedEnvironment $selectedTenant,
|
|
?CanonicalNavigationContext $navigationContext,
|
|
): array {
|
|
$baseQuery = $this->findingExceptionsQuery($workspace, $authorizedTenants, $selectedTenant);
|
|
$count = (clone $baseQuery)->count();
|
|
$pendingCount = (clone $baseQuery)
|
|
->where('status', FindingException::STATUS_PENDING)
|
|
->count();
|
|
$expiringCount = (clone $baseQuery)
|
|
->where('current_validity_state', FindingException::VALIDITY_EXPIRING)
|
|
->count();
|
|
$lapsedCount = (clone $baseQuery)
|
|
->where('status', '!=', FindingException::STATUS_PENDING)
|
|
->whereIn('current_validity_state', [
|
|
FindingException::VALIDITY_EXPIRED,
|
|
FindingException::VALIDITY_MISSING_SUPPORT,
|
|
])
|
|
->count();
|
|
$entries = $this->orderedFindingExceptionsQuery(clone $baseQuery)
|
|
->limit(self::PREVIEW_LIMIT)
|
|
->get()
|
|
->map(fn (FindingException $exception): array => $this->findingExceptionEntry($exception, $navigationContext))
|
|
->all();
|
|
|
|
return [
|
|
'key' => 'finding_exceptions',
|
|
'label' => 'Finding exceptions',
|
|
'count' => $count,
|
|
'summary' => $this->findingExceptionsSummary($count, $pendingCount, $expiringCount, $lapsedCount),
|
|
'dominant_action_label' => 'Open finding exceptions',
|
|
'dominant_action_url' => $this->appendQuery(
|
|
FindingExceptionsQueue::getUrl(
|
|
panel: 'admin',
|
|
parameters: array_filter([
|
|
'tenant' => $selectedTenant?->external_id,
|
|
], static fn (mixed $value): bool => is_string($value) && $value !== ''),
|
|
),
|
|
$navigationContext?->toQuery() ?? [],
|
|
),
|
|
'entries' => $entries,
|
|
'empty_state' => $selectedTenant instanceof ManagedEnvironment
|
|
? 'No finding exceptions match this environment filter right now.'
|
|
: 'No finding exceptions need review right now.',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $tenants
|
|
* @return array<int, ManagedEnvironment>
|
|
*/
|
|
private function indexTenants(array $tenants): array
|
|
{
|
|
$indexed = [];
|
|
|
|
foreach ($tenants as $tenant) {
|
|
$indexed[(int) $tenant->getKey()] = $tenant;
|
|
}
|
|
|
|
return $indexed;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $visibleFindingTenants
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function assignedFindingsSection(
|
|
User $user,
|
|
array $visibleFindingTenants,
|
|
?ManagedEnvironment $selectedTenant,
|
|
?CanonicalNavigationContext $navigationContext,
|
|
): array {
|
|
$baseQuery = $this->assignedFindingsQuery($user, $visibleFindingTenants, $selectedTenant);
|
|
$count = (clone $baseQuery)->count();
|
|
$overdueCount = (clone $baseQuery)
|
|
->whereNotNull('due_at')
|
|
->where('due_at', '<', now())
|
|
->count();
|
|
$entries = $this->orderedAssignedFindingsQuery(clone $baseQuery)
|
|
->limit(self::PREVIEW_LIMIT)
|
|
->get()
|
|
->map(fn (Finding $finding): array => $this->findingEntry($finding, 'assigned_findings', $navigationContext, 10))
|
|
->all();
|
|
|
|
return [
|
|
'key' => 'assigned_findings',
|
|
'label' => 'Assigned findings',
|
|
'count' => $count,
|
|
'summary' => $this->assignedFindingsSummary($count, $overdueCount),
|
|
'dominant_action_label' => 'Open my findings',
|
|
'dominant_action_url' => $this->appendQuery(
|
|
MyFindingsInbox::getUrl(
|
|
panel: 'admin',
|
|
parameters: array_filter([
|
|
'environment_id' => $selectedTenant?->getKey(),
|
|
], static fn (mixed $value): bool => is_numeric($value)),
|
|
),
|
|
$navigationContext?->toQuery() ?? [],
|
|
),
|
|
'entries' => $entries,
|
|
'empty_state' => $selectedTenant instanceof ManagedEnvironment
|
|
? 'No assigned findings match this environment filter right now.'
|
|
: 'No assigned findings are visible right now.',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $visibleFindingTenants
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function intakeFindingsSection(
|
|
array $visibleFindingTenants,
|
|
?ManagedEnvironment $selectedTenant,
|
|
?CanonicalNavigationContext $navigationContext,
|
|
): array {
|
|
$baseQuery = $this->intakeFindingsQuery($visibleFindingTenants, $selectedTenant);
|
|
$count = (clone $baseQuery)->count();
|
|
$needsTriageCount = (clone $baseQuery)
|
|
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED])
|
|
->count();
|
|
$entries = $this->orderedIntakeFindingsQuery(clone $baseQuery)
|
|
->limit(self::PREVIEW_LIMIT)
|
|
->get()
|
|
->map(fn (Finding $finding): array => $this->findingEntry($finding, 'intake_findings', $navigationContext, 20))
|
|
->all();
|
|
|
|
return [
|
|
'key' => 'intake_findings',
|
|
'label' => 'Findings intake',
|
|
'count' => $count,
|
|
'summary' => $this->intakeFindingsSummary($count, $needsTriageCount),
|
|
'dominant_action_label' => 'Open findings intake',
|
|
'dominant_action_url' => $this->appendQuery(
|
|
FindingsIntakeQueue::getUrl(
|
|
panel: 'admin',
|
|
parameters: array_filter([
|
|
'environment_id' => $selectedTenant?->getKey(),
|
|
'view' => $needsTriageCount > 0 ? 'needs_triage' : null,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
),
|
|
$navigationContext?->toQuery() ?? [],
|
|
),
|
|
'entries' => $entries,
|
|
'empty_state' => $selectedTenant instanceof ManagedEnvironment
|
|
? 'No intake findings match this environment filter right now.'
|
|
: 'No intake findings are visible right now.',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $authorizedTenants
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function operationsSection(
|
|
Workspace $workspace,
|
|
array $authorizedTenants,
|
|
?ManagedEnvironment $selectedTenant,
|
|
?CanonicalNavigationContext $navigationContext,
|
|
): array {
|
|
$terminalQuery = $this->terminalOperationsQuery($workspace, $authorizedTenants, $selectedTenant);
|
|
$staleQuery = $this->staleOperationsQuery($workspace, $authorizedTenants, $selectedTenant);
|
|
$terminalCount = (clone $terminalQuery)->count();
|
|
$staleCount = (clone $staleQuery)->count();
|
|
$entries = array_merge(
|
|
(clone $terminalQuery)->latest('completed_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(),
|
|
(clone $staleQuery)->latest('created_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(),
|
|
);
|
|
$entries = collect($entries)
|
|
->unique(fn (OperationRun $run): int => (int) $run->getKey())
|
|
->sortBy([
|
|
fn (OperationRun $run): int => $run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1,
|
|
fn (OperationRun $run): int => -1 * (int) $run->getKey(),
|
|
])
|
|
->take(self::PREVIEW_LIMIT)
|
|
->map(fn (OperationRun $run): array => $this->operationEntry($run, $navigationContext))
|
|
->values()
|
|
->all();
|
|
$dominantProblemClass = $terminalCount > 0
|
|
? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
|
: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION;
|
|
|
|
return [
|
|
'key' => 'stale_operations',
|
|
'label' => 'Operations follow-up',
|
|
'count' => $terminalCount + $staleCount,
|
|
'summary' => $this->operationsSummary($terminalCount, $staleCount),
|
|
'dominant_action_label' => $terminalCount > 0 ? 'Open terminal follow-up' : 'Open stale operations',
|
|
'dominant_action_url' => OperationRunLinks::index(
|
|
tenant: $selectedTenant,
|
|
context: $navigationContext,
|
|
problemClass: $dominantProblemClass,
|
|
workspace: $workspace,
|
|
),
|
|
'entries' => $entries,
|
|
'empty_state' => $selectedTenant instanceof ManagedEnvironment
|
|
? 'No stale or terminal follow-up operations match this environment filter right now.'
|
|
: 'No stale or terminal follow-up operations are visible right now.',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $authorizedTenants
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function alertsSection(
|
|
Workspace $workspace,
|
|
array $authorizedTenants,
|
|
?ManagedEnvironment $selectedTenant,
|
|
?CanonicalNavigationContext $navigationContext,
|
|
): array {
|
|
$baseQuery = $this->alertsQuery($workspace, $authorizedTenants, $selectedTenant);
|
|
$count = (clone $baseQuery)->count();
|
|
$entries = (clone $baseQuery)
|
|
->latest('created_at')
|
|
->latest('id')
|
|
->limit(self::PREVIEW_LIMIT)
|
|
->get()
|
|
->map(fn (AlertDelivery $delivery): array => $this->alertEntry($delivery, $navigationContext))
|
|
->all();
|
|
|
|
return [
|
|
'key' => 'alert_delivery_failures',
|
|
'label' => 'Alert delivery failures',
|
|
'count' => $count,
|
|
'summary' => $this->alertsSummary($count),
|
|
'dominant_action_label' => 'Open alert deliveries',
|
|
'dominant_action_url' => $this->appendQuery(
|
|
AlertDeliveryResource::getUrl(panel: 'admin'),
|
|
array_replace_recursive(
|
|
$navigationContext?->toQuery() ?? [],
|
|
array_filter([
|
|
'environment_id' => $selectedTenant instanceof ManagedEnvironment
|
|
? (int) $selectedTenant->getKey()
|
|
: null,
|
|
'tableFilters' => array_filter([
|
|
'status' => ['value' => AlertDelivery::STATUS_FAILED],
|
|
]),
|
|
], static fn (mixed $value): bool => $value !== null),
|
|
),
|
|
),
|
|
'entries' => $entries,
|
|
'empty_state' => $selectedTenant instanceof ManagedEnvironment
|
|
? 'No failed alert deliveries match this environment filter right now.'
|
|
: 'No failed alert deliveries are visible right now.',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $reviewTenants
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function reviewFollowUpSection(
|
|
User $user,
|
|
Workspace $workspace,
|
|
array $reviewTenants,
|
|
?ManagedEnvironment $selectedTenant,
|
|
?CanonicalNavigationContext $navigationContext,
|
|
): array {
|
|
$tenantIds = $selectedTenant instanceof ManagedEnvironment
|
|
? [(int) $selectedTenant->getKey()]
|
|
: array_keys($reviewTenants);
|
|
$backupHealthByTenant = $this->backupHealthResolver->assessMany($tenantIds);
|
|
$recoveryEvidenceByTenant = $this->restoreSafetyResolver->dashboardRecoveryEvidenceForTenants($tenantIds, $backupHealthByTenant);
|
|
$resolved = $this->managedEnvironmentTriageReviewStateResolver->resolveMany(
|
|
workspaceId: (int) $workspace->getKey(),
|
|
tenantIds: $tenantIds,
|
|
backupHealthByTenant: $backupHealthByTenant,
|
|
recoveryEvidenceByTenant: $recoveryEvidenceByTenant,
|
|
);
|
|
$latestPublishedReviews = $this->environmentReviewRegisterService
|
|
->latestPublishedQuery($user, $workspace)
|
|
->get()
|
|
->keyBy('managed_environment_id')
|
|
->all();
|
|
|
|
$rawEntries = [];
|
|
|
|
foreach ($tenantIds as $tenantId) {
|
|
$tenant = $reviewTenants[$tenantId] ?? null;
|
|
$rows = $resolved['rows'][$tenantId] ?? null;
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! is_array($rows)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ([PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE] as $family) {
|
|
$row = $rows[$family] ?? null;
|
|
|
|
if (! is_array($row) || ($row['current_concern_present'] ?? false) !== true) {
|
|
continue;
|
|
}
|
|
|
|
$derivedState = $row['derived_state'] ?? null;
|
|
|
|
if (! in_array($derivedState, [
|
|
ManagedEnvironmentTriageReview::STATE_FOLLOW_UP_NEEDED,
|
|
ManagedEnvironmentTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
|
|
], true)) {
|
|
continue;
|
|
}
|
|
|
|
$rawEntries[] = $this->reviewEntry(
|
|
tenant: $tenant,
|
|
family: $family,
|
|
row: $row,
|
|
latestPublishedReview: $latestPublishedReviews[$tenantId] ?? null,
|
|
navigationContext: $navigationContext,
|
|
);
|
|
}
|
|
}
|
|
|
|
usort($rawEntries, function (array $left, array $right): int {
|
|
$leftRank = (int) ($left['urgency_rank'] ?? 0);
|
|
$rightRank = (int) ($right['urgency_rank'] ?? 0);
|
|
|
|
if ($leftRank !== $rightRank) {
|
|
return $leftRank <=> $rightRank;
|
|
}
|
|
|
|
return strcmp((string) ($left['headline'] ?? ''), (string) ($right['headline'] ?? ''));
|
|
});
|
|
|
|
$followUpCount = collect($rawEntries)
|
|
->where('status_label', 'Follow-up needed')
|
|
->count();
|
|
$changedCount = collect($rawEntries)
|
|
->where('status_label', 'Changed since review')
|
|
->count();
|
|
|
|
return [
|
|
'key' => 'review_follow_up',
|
|
'label' => 'Review follow-up',
|
|
'count' => count($rawEntries),
|
|
'summary' => $this->reviewSummary($followUpCount, $changedCount),
|
|
'dominant_action_label' => 'Open customer review workspace',
|
|
'dominant_action_url' => $selectedTenant instanceof ManagedEnvironment
|
|
? $this->appendQuery(CustomerReviewWorkspace::environmentFilterUrl($selectedTenant), $navigationContext?->toQuery() ?? [])
|
|
: $this->appendQuery(CustomerReviewWorkspace::getUrl(panel: 'admin'), $navigationContext?->toQuery() ?? []),
|
|
'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT),
|
|
'empty_state' => $selectedTenant instanceof ManagedEnvironment
|
|
? 'No review follow-up is visible for this environment filter right now.'
|
|
: 'No review follow-up is visible right now.',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $visibleFindingTenants
|
|
*/
|
|
private function assignedFindingsQuery(User $user, array $visibleFindingTenants, ?ManagedEnvironment $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
{
|
|
$tenantIds = $selectedTenant instanceof ManagedEnvironment
|
|
? [(int) $selectedTenant->getKey()]
|
|
: array_keys($visibleFindingTenants);
|
|
|
|
return Finding::query()
|
|
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name', 'findingException'])
|
|
->withSubjectDisplayName()
|
|
->whereIn('managed_environment_id', $tenantIds === [] ? [-1] : $tenantIds)
|
|
->where('assignee_user_id', (int) $user->getKey())
|
|
->whereIn('status', Finding::openStatusesForQuery());
|
|
}
|
|
|
|
private function orderedAssignedFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
|
|
{
|
|
return $query
|
|
->orderByRaw(
|
|
'case when due_at is not null and due_at < ? then 0 when reopened_at is not null then 1 else 2 end asc',
|
|
[now()],
|
|
)
|
|
->orderByRaw('case when due_at is null then 1 else 0 end asc')
|
|
->orderBy('due_at')
|
|
->orderByDesc('id');
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $visibleFindingTenants
|
|
*/
|
|
private function intakeFindingsQuery(array $visibleFindingTenants, ?ManagedEnvironment $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
{
|
|
$tenantIds = $selectedTenant instanceof ManagedEnvironment
|
|
? [(int) $selectedTenant->getKey()]
|
|
: array_keys($visibleFindingTenants);
|
|
|
|
return Finding::query()
|
|
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name', 'findingException'])
|
|
->withSubjectDisplayName()
|
|
->whereIn('managed_environment_id', $tenantIds === [] ? [-1] : $tenantIds)
|
|
->whereNull('assignee_user_id')
|
|
->whereIn('status', Finding::openStatusesForQuery());
|
|
}
|
|
|
|
private function orderedIntakeFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
|
|
{
|
|
return $query
|
|
->orderByRaw(
|
|
'case
|
|
when due_at is not null and due_at < ? then 0
|
|
when status = ? then 1
|
|
when status = ? then 2
|
|
else 3
|
|
end asc',
|
|
[now(), Finding::STATUS_REOPENED, Finding::STATUS_NEW],
|
|
)
|
|
->orderByRaw('case when due_at is null then 1 else 0 end asc')
|
|
->orderBy('due_at')
|
|
->orderByDesc('id');
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $authorizedTenants
|
|
*/
|
|
private function terminalOperationsQuery(Workspace $workspace, array $authorizedTenants, ?ManagedEnvironment $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
{
|
|
return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant)
|
|
->terminalFollowUp();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $authorizedTenants
|
|
*/
|
|
private function staleOperationsQuery(Workspace $workspace, array $authorizedTenants, ?ManagedEnvironment $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
{
|
|
return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant)
|
|
->activeStaleAttention();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $authorizedTenants
|
|
*/
|
|
private function operationsBaseQuery(Workspace $workspace, array $authorizedTenants, ?ManagedEnvironment $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
{
|
|
$tenantIds = array_keys($authorizedTenants);
|
|
|
|
return OperationRun::query()
|
|
->with('tenant')
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->where(function ($query) use ($selectedTenant, $tenantIds): void {
|
|
if ($selectedTenant instanceof ManagedEnvironment) {
|
|
$query->where('managed_environment_id', (int) $selectedTenant->getKey());
|
|
|
|
return;
|
|
}
|
|
|
|
$query
|
|
->whereIn('managed_environment_id', $tenantIds === [] ? [-1] : $tenantIds)
|
|
->orWhereNull('managed_environment_id');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $authorizedTenants
|
|
*/
|
|
private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?ManagedEnvironment $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
{
|
|
$tenantIds = array_keys($authorizedTenants);
|
|
|
|
return AlertDelivery::query()
|
|
->with('tenant')
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->where('status', AlertDelivery::STATUS_FAILED)
|
|
->where(function ($query) use ($selectedTenant, $tenantIds): void {
|
|
if ($selectedTenant instanceof ManagedEnvironment) {
|
|
$query->where('managed_environment_id', (int) $selectedTenant->getKey());
|
|
|
|
return;
|
|
}
|
|
|
|
$query
|
|
->whereIn('managed_environment_id', $tenantIds === [] ? [-1] : $tenantIds)
|
|
->orWhereNull('managed_environment_id');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $authorizedTenants
|
|
*/
|
|
private function findingExceptionsQuery(Workspace $workspace, array $authorizedTenants, ?ManagedEnvironment $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
{
|
|
$tenantIds = $selectedTenant instanceof ManagedEnvironment
|
|
? [(int) $selectedTenant->getKey()]
|
|
: array_keys($authorizedTenants);
|
|
|
|
return FindingException::query()
|
|
->with([
|
|
'tenant',
|
|
'requester:id,name',
|
|
'owner:id,name',
|
|
'currentDecision',
|
|
'evidenceReferences',
|
|
'finding' => fn ($query) => $query->withSubjectDisplayName(),
|
|
])
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->whereIn('managed_environment_id', $tenantIds === [] ? [-1] : $tenantIds)
|
|
->where(function ($query): void {
|
|
$query
|
|
->where('status', FindingException::STATUS_PENDING)
|
|
->orWhereIn('status', [
|
|
FindingException::STATUS_EXPIRING,
|
|
FindingException::STATUS_EXPIRED,
|
|
])
|
|
->orWhereIn('current_validity_state', [
|
|
FindingException::VALIDITY_EXPIRING,
|
|
FindingException::VALIDITY_EXPIRED,
|
|
FindingException::VALIDITY_MISSING_SUPPORT,
|
|
]);
|
|
});
|
|
}
|
|
|
|
private function orderedFindingExceptionsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
|
|
{
|
|
return $query
|
|
->orderByRaw(
|
|
'case
|
|
when status = ? then 0
|
|
when current_validity_state = ? then 1
|
|
when current_validity_state = ? then 2
|
|
when current_validity_state = ? then 3
|
|
else 4
|
|
end asc',
|
|
[
|
|
FindingException::STATUS_PENDING,
|
|
FindingException::VALIDITY_EXPIRED,
|
|
FindingException::VALIDITY_MISSING_SUPPORT,
|
|
FindingException::VALIDITY_EXPIRING,
|
|
],
|
|
)
|
|
->orderByRaw('case when review_due_at is null then 1 else 0 end asc')
|
|
->orderBy('review_due_at')
|
|
->orderByDesc('id');
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function findingEntry(Finding $finding, string $familyKey, ?CanonicalNavigationContext $navigationContext, int $baseUrgencyRank): array
|
|
{
|
|
$sublineParts = array_values(array_filter([
|
|
$finding->owner_user_id !== null ? 'Owner: '.FindingResource::accountableOwnerDisplayFor($finding) : null,
|
|
FindingExceptionResource::relativeTimeDescription($finding->due_at) ?? FindingResource::dueAttentionLabelFor($finding),
|
|
$finding->reopened_at !== null ? 'Reopened' : null,
|
|
]));
|
|
|
|
return [
|
|
'family_key' => $familyKey,
|
|
'source_model' => Finding::class,
|
|
'source_key' => (string) $finding->getKey(),
|
|
'managed_environment_id' => $finding->tenant ? (int) $finding->tenant->getKey() : null,
|
|
'tenant_label' => $finding->tenant?->name,
|
|
'headline' => $finding->resolvedSubjectDisplayName() ?? 'Finding #'.$finding->getKey(),
|
|
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
|
|
'urgency_rank' => $baseUrgencyRank
|
|
+ ($finding->due_at?->isPast() === true ? 0 : 1)
|
|
+ ($finding->reopened_at !== null ? 0 : 1),
|
|
'status_label' => Str::of((string) $finding->status)->replace('_', ' ')->title()->value(),
|
|
'destination_url' => $this->appendQuery(
|
|
FindingResource::getUrl('view', ['record' => $finding], tenant: $finding->tenant),
|
|
$navigationContext?->toQuery() ?? [],
|
|
),
|
|
'decision_label' => $familyKey === 'assigned_findings' ? 'Review assigned finding' : 'Triage intake finding',
|
|
'reason_label' => $this->findingReasonLabel($finding, $familyKey),
|
|
'impact_label' => $this->findingImpactLabel($finding),
|
|
'owner_label' => $this->findingOwnerLabel($finding),
|
|
'owner_state' => $finding->owner_user_id !== null ? 'available' : 'missing',
|
|
'due_label' => $this->findingDueLabel($finding),
|
|
'due_state' => $finding->due_at === null ? 'unavailable' : ($finding->due_at->isPast() ? 'overdue' : 'available'),
|
|
'evidence_label' => $this->findingEvidenceLabel($finding),
|
|
'evidence_state' => $this->findingEvidenceState($finding),
|
|
'evidence_path_label' => $this->findingEvidenceState($finding) === 'linked' ? 'Open finding evidence' : 'Source record only',
|
|
'evidence_path_url' => $this->appendQuery(
|
|
FindingResource::getUrl('view', ['record' => $finding], tenant: $finding->tenant),
|
|
$navigationContext?->toQuery() ?? [],
|
|
),
|
|
'exception_label' => $this->findingExceptionLabel($finding),
|
|
'exception_state' => $finding->findingException instanceof FindingException ? 'available' : 'none',
|
|
'primary_action_label' => $familyKey === 'assigned_findings' ? 'Review finding' : 'Triage finding',
|
|
'primary_action_url' => $this->appendQuery(
|
|
FindingResource::getUrl('view', ['record' => $finding], tenant: $finding->tenant),
|
|
$navigationContext?->toQuery() ?? [],
|
|
),
|
|
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function operationEntry(OperationRun $run, ?CanonicalNavigationContext $navigationContext): array
|
|
{
|
|
$problemClass = $run->problemClass();
|
|
|
|
return [
|
|
'family_key' => 'stale_operations',
|
|
'source_model' => OperationRun::class,
|
|
'source_key' => (string) $run->getKey(),
|
|
'managed_environment_id' => $run->tenant ? (int) $run->tenant->getKey() : null,
|
|
'tenant_label' => $run->tenant?->name,
|
|
'headline' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
|
? 'Terminal follow-up operation'
|
|
: 'Stale active operation',
|
|
'subline' => OperationRunLinks::identifier($run),
|
|
'urgency_rank' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1,
|
|
'status_label' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
|
? 'Terminal follow-up'
|
|
: 'Stale',
|
|
'destination_url' => OperationRunLinks::tenantlessView($run, $navigationContext),
|
|
'decision_label' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
|
? 'Review terminal operation follow-up'
|
|
: 'Review stale operation',
|
|
'reason_label' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
|
? 'The operation reached a terminal outcome that still needs monitoring follow-up.'
|
|
: 'The active operation appears stale and needs operator attention.',
|
|
'impact_label' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
|
? 'Terminal operation follow-up'
|
|
: 'Stale active operation',
|
|
'owner_label' => 'Owner unavailable',
|
|
'owner_state' => 'unavailable',
|
|
'due_label' => $run->completed_at instanceof \DateTimeInterface
|
|
? 'Completed '.FindingExceptionResource::relativeTimeDescription($run->completed_at)
|
|
: 'Due date unavailable',
|
|
'due_state' => 'unavailable',
|
|
'evidence_label' => 'Operation proof available',
|
|
'evidence_state' => 'linked',
|
|
'evidence_path_label' => OperationRunLinks::identifier($run),
|
|
'evidence_path_url' => OperationRunLinks::tenantlessView($run, $navigationContext),
|
|
'exception_label' => 'No accepted-risk state',
|
|
'exception_state' => 'not_required',
|
|
'primary_action_label' => 'Open operation proof',
|
|
'primary_action_url' => OperationRunLinks::tenantlessView($run, $navigationContext),
|
|
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext $navigationContext): array
|
|
{
|
|
$payload = is_array($delivery->payload) ? $delivery->payload : [];
|
|
$headline = is_string($payload['title'] ?? null) && $payload['title'] !== ''
|
|
? (string) $payload['title']
|
|
: 'Failed alert delivery';
|
|
$sublineParts = array_values(array_filter([
|
|
is_string($delivery->event_type) && $delivery->event_type !== ''
|
|
? $delivery->event_type
|
|
: null,
|
|
]));
|
|
|
|
return [
|
|
'family_key' => 'alert_delivery_failures',
|
|
'source_model' => AlertDelivery::class,
|
|
'source_key' => (string) $delivery->getKey(),
|
|
'managed_environment_id' => $delivery->tenant ? (int) $delivery->tenant->getKey() : null,
|
|
'tenant_label' => $delivery->tenant?->name,
|
|
'headline' => $headline,
|
|
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
|
|
'urgency_rank' => 0,
|
|
'status_label' => 'Failed',
|
|
'destination_url' => $this->appendQuery(
|
|
AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'),
|
|
$navigationContext?->toQuery() ?? [],
|
|
),
|
|
'decision_label' => 'Review failed alert delivery',
|
|
'reason_label' => 'An alert delivery attempt failed; source diagnostics remain on the alert delivery record.',
|
|
'impact_label' => is_string($delivery->event_type) && $delivery->event_type !== ''
|
|
? 'Alert event: '.$delivery->event_type
|
|
: 'Alert delivery follow-up',
|
|
'owner_label' => 'Owner unavailable',
|
|
'owner_state' => 'unavailable',
|
|
'due_label' => 'Due date unavailable',
|
|
'due_state' => 'unavailable',
|
|
'evidence_label' => 'Evidence not required',
|
|
'evidence_state' => 'not_required',
|
|
'evidence_path_label' => 'Alert delivery record',
|
|
'evidence_path_url' => $this->appendQuery(
|
|
AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'),
|
|
$navigationContext?->toQuery() ?? [],
|
|
),
|
|
'exception_label' => 'No accepted-risk state',
|
|
'exception_state' => 'not_required',
|
|
'primary_action_label' => 'Open alert delivery',
|
|
'primary_action_url' => $this->appendQuery(
|
|
AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'),
|
|
$navigationContext?->toQuery() ?? [],
|
|
),
|
|
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function findingExceptionEntry(FindingException $exception, ?CanonicalNavigationContext $navigationContext): array
|
|
{
|
|
$findingLabel = $exception->finding?->resolvedSubjectDisplayName()
|
|
?? 'Finding #'.$exception->finding_id;
|
|
$sublineParts = array_values(array_filter([
|
|
$exception->owner?->name !== null ? 'Owner: '.$exception->owner->name : null,
|
|
FindingExceptionResource::relativeTimeDescription($exception->review_due_at)
|
|
?? FindingExceptionResource::relativeTimeDescription($exception->expires_at),
|
|
is_string($exception->request_reason) && $exception->request_reason !== ''
|
|
? $exception->request_reason
|
|
: null,
|
|
]));
|
|
|
|
return [
|
|
'family_key' => 'finding_exceptions',
|
|
'source_model' => FindingException::class,
|
|
'source_key' => (string) $exception->getKey(),
|
|
'managed_environment_id' => $exception->tenant ? (int) $exception->tenant->getKey() : null,
|
|
'tenant_label' => $exception->tenant?->name,
|
|
'headline' => $findingLabel,
|
|
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
|
|
'urgency_rank' => match (true) {
|
|
(string) $exception->status === FindingException::STATUS_PENDING => 0,
|
|
(string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRED => 1,
|
|
(string) $exception->current_validity_state === FindingException::VALIDITY_MISSING_SUPPORT => 2,
|
|
(string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRING => 3,
|
|
default => 4,
|
|
},
|
|
'status_label' => $this->findingExceptionStatusLabel($exception),
|
|
'destination_url' => $this->appendQuery(
|
|
FindingExceptionsQueue::getUrl(
|
|
panel: 'admin',
|
|
parameters: array_filter([
|
|
'tenant' => $exception->tenant?->external_id,
|
|
'exception' => (int) $exception->getKey(),
|
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
),
|
|
$navigationContext?->toQuery() ?? [],
|
|
),
|
|
'decision_label' => 'Review accepted-risk decision',
|
|
'reason_label' => is_string($exception->request_reason) && trim($exception->request_reason) !== ''
|
|
? trim($exception->request_reason)
|
|
: 'This accepted-risk or exception record needs review.',
|
|
'impact_label' => $this->findingExceptionImpactLabel($exception),
|
|
'owner_label' => $exception->owner?->name ?? 'Owner missing',
|
|
'owner_state' => $exception->owner?->name !== null ? 'available' : 'missing',
|
|
'due_label' => $this->findingExceptionDueLabel($exception),
|
|
'due_state' => $exception->review_due_at === null && $exception->expires_at === null
|
|
? 'unavailable'
|
|
: (($exception->review_due_at?->isPast() === true || $exception->expires_at?->isPast() === true) ? 'overdue' : 'available'),
|
|
'evidence_label' => $this->findingExceptionEvidenceLabel($exception),
|
|
'evidence_state' => $this->findingExceptionEvidenceState($exception),
|
|
'evidence_path_label' => $this->findingExceptionEvidenceState($exception) === 'linked'
|
|
? 'Open exception proof'
|
|
: 'Source record only',
|
|
'evidence_path_url' => $this->appendQuery(
|
|
FindingExceptionsQueue::getUrl(
|
|
panel: 'admin',
|
|
parameters: array_filter([
|
|
'tenant' => $exception->tenant?->external_id,
|
|
'exception' => (int) $exception->getKey(),
|
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
),
|
|
$navigationContext?->toQuery() ?? [],
|
|
),
|
|
'exception_label' => $this->findingExceptionDecisionStateLabel($exception),
|
|
'exception_state' => 'available',
|
|
'primary_action_label' => 'Review accepted risk',
|
|
'primary_action_url' => $this->appendQuery(
|
|
FindingExceptionsQueue::getUrl(
|
|
panel: 'admin',
|
|
parameters: array_filter([
|
|
'tenant' => $exception->tenant?->external_id,
|
|
'exception' => (int) $exception->getKey(),
|
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
),
|
|
$navigationContext?->toQuery() ?? [],
|
|
),
|
|
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function reviewEntry(
|
|
ManagedEnvironment $tenant,
|
|
string $family,
|
|
array $row,
|
|
mixed $latestPublishedReview,
|
|
?CanonicalNavigationContext $navigationContext,
|
|
): array {
|
|
$state = (string) ($row['derived_state'] ?? ManagedEnvironmentTriageReview::DERIVED_STATE_NOT_REVIEWED);
|
|
$familyLabel = $family === PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH
|
|
? 'Backup health'
|
|
: 'Recovery evidence';
|
|
$headline = $state === ManagedEnvironmentTriageReview::STATE_FOLLOW_UP_NEEDED
|
|
? $familyLabel.' needs review follow-up'
|
|
: $familyLabel.' changed since review';
|
|
$sublineParts = array_values(array_filter([
|
|
is_string($row['reviewed_by_user_name'] ?? null) && $row['reviewed_by_user_name'] !== ''
|
|
? 'Last review: '.$row['reviewed_by_user_name']
|
|
: null,
|
|
isset($row['reviewed_at']) && $row['reviewed_at'] !== null
|
|
? 'Reviewed '.optional($row['reviewed_at'])->toDateTimeString()
|
|
: null,
|
|
]));
|
|
$destinationUrl = $latestPublishedReview !== null
|
|
? EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $latestPublishedReview], $tenant)
|
|
: CustomerReviewWorkspace::environmentFilterUrl($tenant);
|
|
|
|
return [
|
|
'family_key' => 'review_follow_up',
|
|
'source_model' => ManagedEnvironmentTriageReview::class,
|
|
'source_key' => (string) $tenant->getKey().':'.$family,
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'tenant_label' => $tenant->name,
|
|
'headline' => $headline,
|
|
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
|
|
'urgency_rank' => $state === ManagedEnvironmentTriageReview::STATE_FOLLOW_UP_NEEDED ? 0 : 1,
|
|
'status_label' => $state === ManagedEnvironmentTriageReview::STATE_FOLLOW_UP_NEEDED
|
|
? 'Follow-up needed'
|
|
: 'Changed since review',
|
|
'destination_url' => $this->appendQuery($destinationUrl, $navigationContext?->toQuery() ?? []),
|
|
'decision_label' => 'Review customer workspace follow-up',
|
|
'reason_label' => $state === ManagedEnvironmentTriageReview::STATE_FOLLOW_UP_NEEDED
|
|
? 'The latest review state asks for follow-up before this governance concern is closed.'
|
|
: 'This review concern changed since the latest reviewed state.',
|
|
'impact_label' => $familyLabel.' review follow-up',
|
|
'owner_label' => is_string($row['reviewed_by_user_name'] ?? null) && $row['reviewed_by_user_name'] !== ''
|
|
? 'Last reviewer: '.$row['reviewed_by_user_name']
|
|
: 'Owner unavailable',
|
|
'owner_state' => is_string($row['reviewed_by_user_name'] ?? null) && $row['reviewed_by_user_name'] !== ''
|
|
? 'available'
|
|
: 'unavailable',
|
|
'due_label' => 'Due date unavailable',
|
|
'due_state' => 'unavailable',
|
|
'evidence_label' => 'Review evidence path available',
|
|
'evidence_state' => 'linked',
|
|
'evidence_path_label' => 'Open review context',
|
|
'evidence_path_url' => $this->appendQuery($destinationUrl, $navigationContext?->toQuery() ?? []),
|
|
'exception_label' => 'Accepted-risk state unavailable',
|
|
'exception_state' => 'unavailable',
|
|
'primary_action_label' => 'Open review context',
|
|
'primary_action_url' => $this->appendQuery($destinationUrl, $navigationContext?->toQuery() ?? []),
|
|
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
|
];
|
|
}
|
|
|
|
private function assignedFindingsSummary(int $count, int $overdueCount): string
|
|
{
|
|
if ($count === 0) {
|
|
return 'No assigned findings are visible in the current scope.';
|
|
}
|
|
|
|
if ($overdueCount > 0) {
|
|
return sprintf(
|
|
'%d assigned finding%s remain open. %d %s overdue.',
|
|
$count,
|
|
$count === 1 ? '' : 's',
|
|
$overdueCount,
|
|
$overdueCount === 1 ? 'is' : 'are',
|
|
);
|
|
}
|
|
|
|
return sprintf(
|
|
'%d assigned finding%s remain open in the visible scope.',
|
|
$count,
|
|
$count === 1 ? '' : 's',
|
|
);
|
|
}
|
|
|
|
private function intakeFindingsSummary(int $count, int $needsTriageCount): string
|
|
{
|
|
if ($count === 0) {
|
|
return 'No intake findings are visible in the current scope.';
|
|
}
|
|
|
|
return sprintf(
|
|
'%d unassigned finding%s remain in intake. %d still need first triage.',
|
|
$count,
|
|
$count === 1 ? '' : 's',
|
|
$needsTriageCount,
|
|
);
|
|
}
|
|
|
|
private function operationsSummary(int $terminalCount, int $staleCount): string
|
|
{
|
|
if ($terminalCount + $staleCount === 0) {
|
|
return 'No stale or terminal follow-up operations are visible in the current scope.';
|
|
}
|
|
|
|
if ($terminalCount > 0 && $staleCount > 0) {
|
|
return sprintf(
|
|
'%d terminal follow-up operation%s and %d stale active run%s need monitoring attention.',
|
|
$terminalCount,
|
|
$terminalCount === 1 ? '' : 's',
|
|
$staleCount,
|
|
$staleCount === 1 ? '' : 's',
|
|
);
|
|
}
|
|
|
|
if ($terminalCount > 0) {
|
|
return sprintf(
|
|
'%d terminal follow-up operation%s need monitoring attention.',
|
|
$terminalCount,
|
|
$terminalCount === 1 ? '' : 's',
|
|
);
|
|
}
|
|
|
|
return sprintf(
|
|
'%d stale active run%s need monitoring attention.',
|
|
$staleCount,
|
|
$staleCount === 1 ? '' : 's',
|
|
);
|
|
}
|
|
|
|
private function alertsSummary(int $count): string
|
|
{
|
|
if ($count === 0) {
|
|
return 'No failed alert deliveries are visible in the current scope.';
|
|
}
|
|
|
|
return sprintf(
|
|
'%d failed alert delivery attempt%s remain visible in this workspace.',
|
|
$count,
|
|
$count === 1 ? '' : 's',
|
|
);
|
|
}
|
|
|
|
private function findingExceptionsSummary(int $count, int $pendingCount, int $expiringCount, int $lapsedCount): string
|
|
{
|
|
if ($count === 0) {
|
|
return 'No finding exceptions need review in the current scope.';
|
|
}
|
|
|
|
return sprintf(
|
|
'%d finding exception%s need review. %d pending, %d expiring, and %d lapsed or missing support.',
|
|
$count,
|
|
$count === 1 ? '' : 's',
|
|
$pendingCount,
|
|
$expiringCount,
|
|
$lapsedCount,
|
|
);
|
|
}
|
|
|
|
private function findingExceptionStatusLabel(FindingException $exception): string
|
|
{
|
|
if ((string) $exception->status === FindingException::STATUS_PENDING) {
|
|
return 'Pending';
|
|
}
|
|
|
|
if (in_array((string) $exception->current_validity_state, [
|
|
FindingException::VALIDITY_EXPIRING,
|
|
FindingException::VALIDITY_EXPIRED,
|
|
FindingException::VALIDITY_MISSING_SUPPORT,
|
|
], true)) {
|
|
return Str::of((string) $exception->current_validity_state)->replace('_', ' ')->title()->value();
|
|
}
|
|
|
|
return Str::of((string) $exception->status)->replace('_', ' ')->title()->value();
|
|
}
|
|
|
|
private function findingReasonLabel(Finding $finding, string $familyKey): string
|
|
{
|
|
if ($finding->due_at?->isPast() === true) {
|
|
return 'The finding is overdue and still open in the current governance scope.';
|
|
}
|
|
|
|
if ($finding->reopened_at !== null) {
|
|
return 'The finding reopened after a previous resolution path.';
|
|
}
|
|
|
|
if ($familyKey === 'intake_findings') {
|
|
return 'The finding is unassigned and still needs first triage.';
|
|
}
|
|
|
|
return 'The finding remains assigned and needs follow-up before it can be cleared.';
|
|
}
|
|
|
|
private function findingImpactLabel(Finding $finding): string
|
|
{
|
|
$severity = is_string($finding->severity) && $finding->severity !== ''
|
|
? Str::of($finding->severity)->replace('_', ' ')->title()->value()
|
|
: 'Severity unavailable';
|
|
$type = is_string($finding->finding_type) && $finding->finding_type !== ''
|
|
? Str::of($finding->finding_type)->replace('_', ' ')->lower()->value()
|
|
: 'finding';
|
|
|
|
return $severity.' '.$type;
|
|
}
|
|
|
|
private function findingOwnerLabel(Finding $finding): string
|
|
{
|
|
if ($finding->ownerUser?->name !== null) {
|
|
return $finding->ownerUser->name;
|
|
}
|
|
|
|
if ($finding->assigneeUser?->name !== null) {
|
|
return 'Active assignee: '.$finding->assigneeUser->name;
|
|
}
|
|
|
|
return 'Owner missing';
|
|
}
|
|
|
|
private function findingDueLabel(Finding $finding): string
|
|
{
|
|
if (! $finding->due_at instanceof \DateTimeInterface) {
|
|
return 'Due date unavailable';
|
|
}
|
|
|
|
$relative = FindingExceptionResource::relativeTimeDescription($finding->due_at);
|
|
|
|
if ($finding->due_at->isPast()) {
|
|
return 'Overdue'.($relative !== null ? ': '.$relative : '');
|
|
}
|
|
|
|
return $relative ?? $finding->due_at->format('Y-m-d');
|
|
}
|
|
|
|
private function findingEvidenceState(Finding $finding): string
|
|
{
|
|
return is_array($finding->evidence_jsonb) && $finding->evidence_jsonb !== []
|
|
? 'linked'
|
|
: 'missing';
|
|
}
|
|
|
|
private function findingEvidenceLabel(Finding $finding): string
|
|
{
|
|
return $this->findingEvidenceState($finding) === 'linked'
|
|
? 'Evidence captured on finding'
|
|
: 'Evidence missing';
|
|
}
|
|
|
|
private function findingExceptionLabel(Finding $finding): string
|
|
{
|
|
$exception = $finding->relationLoaded('findingException') ? $finding->findingException : null;
|
|
|
|
if ($exception instanceof FindingException) {
|
|
return $this->findingExceptionDecisionStateLabel($exception);
|
|
}
|
|
|
|
return 'No accepted risk';
|
|
}
|
|
|
|
private function findingExceptionImpactLabel(FindingException $exception): string
|
|
{
|
|
return match ((string) $exception->current_validity_state) {
|
|
FindingException::VALIDITY_EXPIRING => 'Accepted risk expiring',
|
|
FindingException::VALIDITY_EXPIRED => 'Accepted risk expired',
|
|
FindingException::VALIDITY_MISSING_SUPPORT => 'Accepted risk missing support',
|
|
FindingException::VALIDITY_VALID => 'Accepted risk active',
|
|
default => 'Exception review required',
|
|
};
|
|
}
|
|
|
|
private function findingExceptionDueLabel(FindingException $exception): string
|
|
{
|
|
$date = $exception->review_due_at ?? $exception->expires_at;
|
|
|
|
if (! $date instanceof \DateTimeInterface) {
|
|
return 'Due date unavailable';
|
|
}
|
|
|
|
$relative = FindingExceptionResource::relativeTimeDescription($date);
|
|
|
|
if ($date->isPast()) {
|
|
return 'Overdue'.($relative !== null ? ': '.$relative : '');
|
|
}
|
|
|
|
return $relative ?? $date->format('Y-m-d');
|
|
}
|
|
|
|
private function findingExceptionEvidenceState(FindingException $exception): string
|
|
{
|
|
$summaryCount = data_get($exception->evidence_summary, 'reference_count');
|
|
$referenceCount = is_numeric($summaryCount)
|
|
? (int) $summaryCount
|
|
: ($exception->relationLoaded('evidenceReferences') ? $exception->evidenceReferences->count() : 0);
|
|
|
|
return $referenceCount > 0 ? 'linked' : 'missing';
|
|
}
|
|
|
|
private function findingExceptionEvidenceLabel(FindingException $exception): string
|
|
{
|
|
return $this->findingExceptionEvidenceState($exception) === 'linked'
|
|
? 'Evidence references linked'
|
|
: 'Evidence missing';
|
|
}
|
|
|
|
private function findingExceptionDecisionStateLabel(FindingException $exception): string
|
|
{
|
|
if ((string) $exception->status === FindingException::STATUS_PENDING) {
|
|
return 'Pending exception';
|
|
}
|
|
|
|
return match ((string) $exception->current_validity_state) {
|
|
FindingException::VALIDITY_VALID => 'Accepted risk active',
|
|
FindingException::VALIDITY_EXPIRING => 'Accepted risk expiring',
|
|
FindingException::VALIDITY_EXPIRED => 'Accepted risk expired',
|
|
FindingException::VALIDITY_MISSING_SUPPORT => 'Accepted risk missing support',
|
|
default => Str::of((string) $exception->status)->replace('_', ' ')->title()->value(),
|
|
};
|
|
}
|
|
|
|
private function reviewSummary(int $followUpCount, int $changedCount): string
|
|
{
|
|
$total = $followUpCount + $changedCount;
|
|
|
|
if ($total === 0) {
|
|
return 'No review follow-up is visible in the current scope.';
|
|
}
|
|
|
|
return sprintf(
|
|
'%d review concern%s need attention. %d marked follow-up needed and %d changed since review.',
|
|
$total,
|
|
$total === 1 ? '' : 's',
|
|
$followUpCount,
|
|
$changedCount,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $query
|
|
*/
|
|
private function appendQuery(string $url, array $query): string
|
|
{
|
|
if ($query === []) {
|
|
return $url;
|
|
}
|
|
|
|
$separator = str_contains($url, '?') ? '&' : '?';
|
|
|
|
return $url.$separator.http_build_query($query);
|
|
}
|
|
}
|