Some checks failed
Main Confidence / confidence (push) Failing after 57s
## Summary - add a read-first governance inbox page at `/admin/governance/inbox` - aggregate assigned findings, intake, stale operations, alert-delivery failures, and review follow-up into one canonical routing surface - add focused coverage for inbox authorization, navigation context, page behavior, and section builder logic - include the Spec Kit artifacts for spec 250 ## Notes - branch is synced with `dev` - this PR supersedes #290 for the governance inbox work Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #291
888 lines
35 KiB
PHP
888 lines
35 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\Reviews\CustomerReviewWorkspace;
|
|
use App\Filament\Resources\AlertDeliveryResource;
|
|
use App\Filament\Resources\FindingExceptionResource;
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Filament\Resources\TenantResource;
|
|
use App\Filament\Resources\TenantReviewResource;
|
|
use App\Models\AlertDelivery;
|
|
use App\Models\Finding;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantTriageReview;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\TenantReviews\TenantReviewRegisterService;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
|
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
|
use App\Support\Navigation\CanonicalNavigationContext;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
|
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
|
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
|
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
|
use Illuminate\Support\Str;
|
|
|
|
final readonly class GovernanceInboxSectionBuilder
|
|
{
|
|
private const PREVIEW_LIMIT = 3;
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const FAMILY_ORDER = [
|
|
'assigned_findings',
|
|
'intake_findings',
|
|
'stale_operations',
|
|
'alert_delivery_failures',
|
|
'review_follow_up',
|
|
];
|
|
|
|
public function __construct(
|
|
private TenantBackupHealthResolver $backupHealthResolver,
|
|
private RestoreSafetyResolver $restoreSafetyResolver,
|
|
private TenantTriageReviewStateResolver $tenantTriageReviewStateResolver,
|
|
private TenantReviewRegisterService $tenantReviewRegisterService,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<int, Tenant> $authorizedTenants
|
|
* @param array<int, Tenant> $visibleFindingTenants
|
|
* @param array<int, Tenant> $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,
|
|
?Tenant $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 !== []) {
|
|
$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, Tenant> $tenants
|
|
* @return array<int, Tenant>
|
|
*/
|
|
private function indexTenants(array $tenants): array
|
|
{
|
|
$indexed = [];
|
|
|
|
foreach ($tenants as $tenant) {
|
|
$indexed[(int) $tenant->getKey()] = $tenant;
|
|
}
|
|
|
|
return $indexed;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, Tenant> $visibleFindingTenants
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function assignedFindingsSection(
|
|
User $user,
|
|
array $visibleFindingTenants,
|
|
?Tenant $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([
|
|
'tenant' => $selectedTenant?->external_id,
|
|
], static fn (mixed $value): bool => is_string($value) && $value !== ''),
|
|
),
|
|
$navigationContext?->toQuery() ?? [],
|
|
),
|
|
'entries' => $entries,
|
|
'empty_state' => $selectedTenant instanceof Tenant
|
|
? 'No assigned findings match this tenant filter right now.'
|
|
: 'No assigned findings are visible right now.',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, Tenant> $visibleFindingTenants
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function intakeFindingsSection(
|
|
array $visibleFindingTenants,
|
|
?Tenant $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([
|
|
'tenant' => $selectedTenant?->external_id,
|
|
'view' => $needsTriageCount > 0 ? 'needs_triage' : null,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
),
|
|
$navigationContext?->toQuery() ?? [],
|
|
),
|
|
'entries' => $entries,
|
|
'empty_state' => $selectedTenant instanceof Tenant
|
|
? 'No intake findings match this tenant filter right now.'
|
|
: 'No intake findings are visible right now.',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, Tenant> $authorizedTenants
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function operationsSection(
|
|
Workspace $workspace,
|
|
array $authorizedTenants,
|
|
?Tenant $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,
|
|
),
|
|
'entries' => $entries,
|
|
'empty_state' => $selectedTenant instanceof Tenant
|
|
? 'No stale or terminal follow-up operations match this tenant filter right now.'
|
|
: 'No stale or terminal follow-up operations are visible right now.',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, Tenant> $authorizedTenants
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function alertsSection(
|
|
Workspace $workspace,
|
|
array $authorizedTenants,
|
|
?Tenant $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() ?? [],
|
|
[
|
|
'tableFilters' => array_filter([
|
|
'status' => ['value' => AlertDelivery::STATUS_FAILED],
|
|
'tenant_id' => $selectedTenant instanceof Tenant
|
|
? ['value' => (string) $selectedTenant->getKey()]
|
|
: null,
|
|
], static fn (mixed $value): bool => $value !== null),
|
|
],
|
|
),
|
|
),
|
|
'entries' => $entries,
|
|
'empty_state' => $selectedTenant instanceof Tenant
|
|
? 'No failed alert deliveries match this tenant filter right now.'
|
|
: 'No failed alert deliveries are visible right now.',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, Tenant> $reviewTenants
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function reviewFollowUpSection(
|
|
User $user,
|
|
Workspace $workspace,
|
|
array $reviewTenants,
|
|
?Tenant $selectedTenant,
|
|
?CanonicalNavigationContext $navigationContext,
|
|
): array {
|
|
$tenantIds = $selectedTenant instanceof Tenant
|
|
? [(int) $selectedTenant->getKey()]
|
|
: array_keys($reviewTenants);
|
|
$backupHealthByTenant = $this->backupHealthResolver->assessMany($tenantIds);
|
|
$recoveryEvidenceByTenant = $this->restoreSafetyResolver->dashboardRecoveryEvidenceForTenants($tenantIds, $backupHealthByTenant);
|
|
$resolved = $this->tenantTriageReviewStateResolver->resolveMany(
|
|
workspaceId: (int) $workspace->getKey(),
|
|
tenantIds: $tenantIds,
|
|
backupHealthByTenant: $backupHealthByTenant,
|
|
recoveryEvidenceByTenant: $recoveryEvidenceByTenant,
|
|
);
|
|
$latestPublishedReviews = $this->tenantReviewRegisterService
|
|
->latestPublishedQuery($user, $workspace)
|
|
->get()
|
|
->keyBy('tenant_id')
|
|
->all();
|
|
|
|
$rawEntries = [];
|
|
|
|
foreach ($tenantIds as $tenantId) {
|
|
$tenant = $reviewTenants[$tenantId] ?? null;
|
|
$rows = $resolved['rows'][$tenantId] ?? null;
|
|
|
|
if (! $tenant instanceof Tenant || ! 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, [
|
|
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
|
|
TenantTriageReview::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 review follow-up',
|
|
'dominant_action_url' => $selectedTenant instanceof Tenant
|
|
? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? [])
|
|
: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive(
|
|
$navigationContext?->toQuery() ?? [],
|
|
[
|
|
'backup_posture' => [
|
|
TenantBackupHealthAssessment::POSTURE_ABSENT,
|
|
TenantBackupHealthAssessment::POSTURE_STALE,
|
|
TenantBackupHealthAssessment::POSTURE_DEGRADED,
|
|
],
|
|
'recovery_evidence' => [
|
|
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
|
|
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
|
|
],
|
|
'review_state' => [
|
|
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
|
|
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
|
|
],
|
|
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
|
],
|
|
)),
|
|
'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT),
|
|
'empty_state' => $selectedTenant instanceof Tenant
|
|
? 'No review follow-up is visible for this tenant filter right now.'
|
|
: 'No review follow-up is visible right now.',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, Tenant> $visibleFindingTenants
|
|
*/
|
|
private function assignedFindingsQuery(User $user, array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
{
|
|
$tenantIds = $selectedTenant instanceof Tenant
|
|
? [(int) $selectedTenant->getKey()]
|
|
: array_keys($visibleFindingTenants);
|
|
|
|
return Finding::query()
|
|
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name'])
|
|
->withSubjectDisplayName()
|
|
->whereIn('tenant_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, Tenant> $visibleFindingTenants
|
|
*/
|
|
private function intakeFindingsQuery(array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
{
|
|
$tenantIds = $selectedTenant instanceof Tenant
|
|
? [(int) $selectedTenant->getKey()]
|
|
: array_keys($visibleFindingTenants);
|
|
|
|
return Finding::query()
|
|
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name'])
|
|
->withSubjectDisplayName()
|
|
->whereIn('tenant_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, Tenant> $authorizedTenants
|
|
*/
|
|
private function terminalOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
{
|
|
return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant)
|
|
->terminalFollowUp();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, Tenant> $authorizedTenants
|
|
*/
|
|
private function staleOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
{
|
|
return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant)
|
|
->activeStaleAttention();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, Tenant> $authorizedTenants
|
|
*/
|
|
private function operationsBaseQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $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 Tenant) {
|
|
$query->where('tenant_id', (int) $selectedTenant->getKey());
|
|
|
|
return;
|
|
}
|
|
|
|
$query
|
|
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
|
|
->orWhereNull('tenant_id');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<int, Tenant> $authorizedTenants
|
|
*/
|
|
private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $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 Tenant) {
|
|
$query->where('tenant_id', (int) $selectedTenant->getKey());
|
|
|
|
return;
|
|
}
|
|
|
|
$query
|
|
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
|
|
->orWhereNull('tenant_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(),
|
|
'tenant_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], panel: 'tenant', 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(),
|
|
'tenant_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),
|
|
'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->last_error_message) && $delivery->last_error_message !== ''
|
|
? $delivery->last_error_message
|
|
: null,
|
|
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(),
|
|
'tenant_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() ?? [],
|
|
),
|
|
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function reviewEntry(
|
|
Tenant $tenant,
|
|
string $family,
|
|
array $row,
|
|
mixed $latestPublishedReview,
|
|
?CanonicalNavigationContext $navigationContext,
|
|
): array {
|
|
$state = (string) ($row['derived_state'] ?? TenantTriageReview::DERIVED_STATE_NOT_REVIEWED);
|
|
$familyLabel = $family === PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH
|
|
? 'Backup health'
|
|
: 'Recovery evidence';
|
|
$headline = $state === TenantTriageReview::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
|
|
? TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview], $tenant, 'tenant')
|
|
: CustomerReviewWorkspace::tenantPrefilterUrl($tenant);
|
|
|
|
return [
|
|
'family_key' => 'review_follow_up',
|
|
'source_model' => TenantTriageReview::class,
|
|
'source_key' => (string) $tenant->getKey().':'.$family,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'tenant_label' => $tenant->name,
|
|
'headline' => $headline,
|
|
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
|
|
'urgency_rank' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED ? 0 : 1,
|
|
'status_label' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED
|
|
? 'Follow-up needed'
|
|
: 'Changed since review',
|
|
'destination_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 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);
|
|
}
|
|
} |