## Summary - add tenant triage review-state persistence, fingerprinting, resolver logic, service layer, and migration for current affected-set tracking - surface review-state and affected-set progress across tenant registry, tenant dashboard arrival continuity, and workspace overview - extend RBAC, audit/badge support, specs, and test coverage for portfolio triage review-state workflows - suppress expected hidden-page background transport failures in the global unhandled rejection logger while keeping visible-page failures logged ## Validation - targeted Pest coverage added for tenant registry, workspace overview, arrival context, RBAC authorization, badges, fingerprinting, resolver behavior, and logger asset behavior - code formatted with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` ## Notes - full suite was not re-run in this final step - branch includes the spec artifacts under `specs/189-portfolio-triage-review-state/` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #220
193 lines
7.4 KiB
PHP
193 lines
7.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\PortfolioTriage;
|
|
|
|
use App\Models\TenantTriageReview;
|
|
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
|
|
|
final readonly class TenantTriageReviewStateResolver
|
|
{
|
|
public function __construct(
|
|
private TenantTriageReviewFingerprint $fingerprints,
|
|
) {}
|
|
|
|
/**
|
|
* @param list<int> $tenantIds
|
|
* @param array<int, TenantBackupHealthAssessment> $backupHealthByTenant
|
|
* @param array<int, array<string, mixed>> $recoveryEvidenceByTenant
|
|
* @return array{
|
|
* rows: array<int, array{
|
|
* backup_health: array<string, mixed>,
|
|
* recovery_evidence: array<string, mixed>
|
|
* }>,
|
|
* summaries: array<string, array{
|
|
* concern_family: string,
|
|
* affected_total: int,
|
|
* reviewed_count: int,
|
|
* follow_up_needed_count: int,
|
|
* changed_since_review_count: int,
|
|
* not_reviewed_count: int
|
|
* }>
|
|
* }
|
|
*/
|
|
public function resolveMany(
|
|
int $workspaceId,
|
|
array $tenantIds,
|
|
array $backupHealthByTenant = [],
|
|
array $recoveryEvidenceByTenant = [],
|
|
): array {
|
|
$tenantIds = array_values(array_unique(array_map(static fn (int|string $tenantId): int => (int) $tenantId, $tenantIds)));
|
|
|
|
$emptySummary = [
|
|
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => $this->emptySummary(PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH),
|
|
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => $this->emptySummary(PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE),
|
|
];
|
|
|
|
if ($workspaceId <= 0 || $tenantIds === []) {
|
|
return [
|
|
'rows' => [],
|
|
'summaries' => $emptySummary,
|
|
];
|
|
}
|
|
|
|
$activeReviews = TenantTriageReview::query()
|
|
->with('reviewer:id,name,email')
|
|
->forWorkspace($workspaceId)
|
|
->whereIn('tenant_id', $tenantIds)
|
|
->active()
|
|
->orderByDesc('reviewed_at')
|
|
->orderByDesc('id')
|
|
->get()
|
|
->groupBy([
|
|
'tenant_id',
|
|
'concern_family',
|
|
]);
|
|
|
|
$rows = [];
|
|
$summaries = $emptySummary;
|
|
|
|
foreach ($tenantIds as $tenantId) {
|
|
$rows[$tenantId] = [
|
|
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => $this->resolveFamily(
|
|
tenantId: $tenantId,
|
|
concernFamily: PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
|
currentConcern: $this->fingerprints->forBackupHealth($backupHealthByTenant[$tenantId] ?? null),
|
|
activeReview: $activeReviews[$tenantId][PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH][0] ?? null,
|
|
),
|
|
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => $this->resolveFamily(
|
|
tenantId: $tenantId,
|
|
concernFamily: PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
|
|
currentConcern: $this->fingerprints->forRecoveryEvidence($recoveryEvidenceByTenant[$tenantId] ?? null),
|
|
activeReview: $activeReviews[$tenantId][PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE][0] ?? null,
|
|
),
|
|
];
|
|
|
|
foreach ($rows[$tenantId] as $family => $row) {
|
|
if (($row['current_concern_present'] ?? false) !== true) {
|
|
continue;
|
|
}
|
|
|
|
$summaries[$family]['affected_total']++;
|
|
|
|
match ($row['derived_state']) {
|
|
TenantTriageReview::STATE_REVIEWED => $summaries[$family]['reviewed_count']++,
|
|
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $summaries[$family]['follow_up_needed_count']++,
|
|
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW => $summaries[$family]['changed_since_review_count']++,
|
|
default => $summaries[$family]['not_reviewed_count']++,
|
|
};
|
|
}
|
|
}
|
|
|
|
return [
|
|
'rows' => $rows,
|
|
'summaries' => $summaries,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{
|
|
* concern_family: string,
|
|
* concern_state: string,
|
|
* fingerprint: string,
|
|
* snapshot: array<string, mixed>
|
|
* }|null $currentConcern
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function resolveFamily(
|
|
int $tenantId,
|
|
string $concernFamily,
|
|
?array $currentConcern,
|
|
?TenantTriageReview $activeReview,
|
|
): array {
|
|
$reviewerName = null;
|
|
|
|
if ($activeReview?->reviewer !== null) {
|
|
$reviewerName = filled($activeReview->reviewer->name)
|
|
? (string) $activeReview->reviewer->name
|
|
: (filled($activeReview->reviewer->email) ? (string) $activeReview->reviewer->email : null);
|
|
}
|
|
|
|
if ($currentConcern === null) {
|
|
return [
|
|
'tenant_id' => $tenantId,
|
|
'concern_family' => $concernFamily,
|
|
'current_concern_present' => false,
|
|
'current_state' => null,
|
|
'current_fingerprint' => null,
|
|
'review_fingerprint' => $activeReview?->review_fingerprint,
|
|
'derived_state' => null,
|
|
'reviewed_at' => $activeReview?->reviewed_at,
|
|
'reviewed_by_user_id' => $activeReview?->reviewed_by_user_id,
|
|
'reviewed_by_user_name' => $reviewerName,
|
|
'current_snapshot' => null,
|
|
'review_snapshot' => is_array($activeReview?->review_snapshot) ? $activeReview->review_snapshot : null,
|
|
];
|
|
}
|
|
|
|
$derivedState = match (true) {
|
|
! $activeReview instanceof TenantTriageReview => TenantTriageReview::DERIVED_STATE_NOT_REVIEWED,
|
|
$activeReview->review_fingerprint !== $currentConcern['fingerprint'] => TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
|
|
default => (string) $activeReview->current_state,
|
|
};
|
|
|
|
return [
|
|
'tenant_id' => $tenantId,
|
|
'concern_family' => $concernFamily,
|
|
'current_concern_present' => true,
|
|
'current_state' => $currentConcern['concern_state'],
|
|
'current_fingerprint' => $currentConcern['fingerprint'],
|
|
'review_fingerprint' => $activeReview?->review_fingerprint,
|
|
'derived_state' => $derivedState,
|
|
'reviewed_at' => $activeReview?->reviewed_at,
|
|
'reviewed_by_user_id' => $activeReview?->reviewed_by_user_id,
|
|
'reviewed_by_user_name' => $reviewerName,
|
|
'current_snapshot' => $currentConcern['snapshot'],
|
|
'review_snapshot' => is_array($activeReview?->review_snapshot) ? $activeReview->review_snapshot : $currentConcern['snapshot'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* concern_family: string,
|
|
* affected_total: int,
|
|
* reviewed_count: int,
|
|
* follow_up_needed_count: int,
|
|
* changed_since_review_count: int,
|
|
* not_reviewed_count: int
|
|
* }
|
|
*/
|
|
private function emptySummary(string $concernFamily): array
|
|
{
|
|
return [
|
|
'concern_family' => $concernFamily,
|
|
'affected_total' => 0,
|
|
'reviewed_count' => 0,
|
|
'follow_up_needed_count' => 0,
|
|
'changed_since_review_count' => 0,
|
|
'not_reviewed_count' => 0,
|
|
];
|
|
}
|
|
}
|