TenantAtlas/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationReadinessEvaluator.php
ahmido ba7622a158 feat: implement ReviewPublicationResolutionWorkflow (Spec 386) (#457)
## Summary\n- Implements the ReviewPublicationResolutionWorkflow for Spec 386.\n- Adds resolution case/step persistence, policies, services, audit action IDs, and Filament integration.\n- Updates specs, UI/UX documentation, screenshots, and Pest coverage.\n\n## Tests\n- Not run during this handoff; branch was already clean and pushed.\n\n## Target\n- Base: platform-dev\n- Head/topic: 386-review-publication-resolution-workflow-v1

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #457
2026-06-18 21:06:20 +00:00

204 lines
8.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\ReviewPublicationResolution;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\EvidenceSnapshotItem;
use App\Models\ReviewPack;
use App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate;
use App\Support\EnvironmentReviewCompletenessState;
use App\Support\EnvironmentReviewStatus;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
use Illuminate\Support\Collection;
final class ReviewPublicationReadinessEvaluator
{
private const array REQUIRED_REPORT_DIMENSIONS = [
'permission_posture',
'entra_admin_roles',
];
public function __construct(
private readonly EnvironmentReviewReadinessGate $readinessGate,
) {}
/**
* @return array{
* fingerprint:string,
* has_publication_blockers:bool,
* can_publish:bool,
* can_return_to_publication:bool,
* missing_report_dimensions:list<string>,
* evidence_incomplete:bool,
* evidence_state:string,
* report_dimension_states:array<string, array{state:string,source_record_id:?int}>,
* review_requires_refresh:bool,
* review_status:string,
* review_completeness_state:string,
* publication_blockers:list<string>,
* guidance_state:string,
* readiness:array<string, mixed>,
* guidance:array<string, mixed>,
* has_ready_export:bool,
* current_export_review_pack_id:?int,
* current_evidence_snapshot_id:?int,
* review_operation_run_id:?int,
* scope:array<string, int>
* }
*/
public function evaluate(EnvironmentReview $review): array
{
$review->loadMissing([
'tenant',
'sections',
'evidenceSnapshot.items',
'operationRun',
'currentExportReviewPack.operationRun',
]);
$readiness = ReviewPackOutputResolutionGuidance::readinessForReview($review);
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness);
$snapshot = $review->evidenceSnapshot;
$pack = $review->currentExportReviewPack;
$publicationBlockers = $this->publicationBlockers($review);
$canPublish = $this->readinessGate->canPublish($review);
$reportDimensionStates = $this->reportDimensionStates($snapshot);
$missingReportDimensions = $this->missingReportDimensions($reportDimensionStates);
$evidenceState = $snapshot instanceof EvidenceSnapshot
? (string) $snapshot->completeness_state
: EvidenceCompletenessState::Missing->value;
$evidenceIncomplete = $snapshot === null
|| $evidenceState !== EvidenceCompletenessState::Complete->value
|| (int) data_get($snapshot->summary, 'missing_dimensions', 0) > 0
|| (int) data_get($snapshot->summary, 'stale_dimensions', 0) > 0;
$reviewStatus = (string) $review->status;
$reviewCompleteness = (string) $review->completeness_state;
$hasReadyExport = (bool) ($readiness['has_ready_export'] ?? false);
$canReturnToPublication = $canPublish && $reviewStatus === EnvironmentReviewStatus::Ready->value;
$reviewRequiresRefresh = ! $canReturnToPublication
|| $reviewCompleteness !== EnvironmentReviewCompletenessState::Complete->value;
$payload = [
'review_id' => (int) $review->getKey(),
'review_status' => $reviewStatus,
'review_completeness_state' => $reviewCompleteness,
'review_fingerprint' => (string) $review->fingerprint,
'review_updated_at' => $review->updated_at?->toJSON(),
'evidence_snapshot_id' => $snapshot instanceof EvidenceSnapshot ? (int) $snapshot->getKey() : null,
'evidence_fingerprint' => $snapshot instanceof EvidenceSnapshot ? (string) $snapshot->fingerprint : null,
'evidence_state' => $evidenceState,
'evidence_generated_at' => $snapshot?->generated_at?->toJSON(),
'report_dimension_states' => $reportDimensionStates,
'section_states' => $this->sectionStates($review),
'publication_blockers' => $publicationBlockers,
'readiness_state' => (string) ($readiness['readiness_state'] ?? ''),
'guidance_state' => (string) ($guidance['state'] ?? ''),
'has_ready_export' => $hasReadyExport,
'review_pack_id' => $pack instanceof ReviewPack ? (int) $pack->getKey() : null,
'review_pack_status' => $pack instanceof ReviewPack ? (string) $pack->status : null,
'review_pack_fingerprint' => $pack instanceof ReviewPack ? (string) $pack->fingerprint : null,
];
return [
'fingerprint' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)),
'has_publication_blockers' => ! $canPublish || $publicationBlockers !== [],
'can_publish' => $canPublish,
'can_return_to_publication' => $canReturnToPublication,
'missing_report_dimensions' => $missingReportDimensions,
'evidence_incomplete' => $evidenceIncomplete,
'evidence_state' => $evidenceState,
'report_dimension_states' => $reportDimensionStates,
'review_requires_refresh' => $reviewRequiresRefresh,
'review_status' => $reviewStatus,
'review_completeness_state' => $reviewCompleteness,
'publication_blockers' => $publicationBlockers,
'guidance_state' => (string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN),
'readiness' => $readiness,
'guidance' => $guidance,
'has_ready_export' => $hasReadyExport,
'current_export_review_pack_id' => $pack instanceof ReviewPack ? (int) $pack->getKey() : null,
'current_evidence_snapshot_id' => $snapshot instanceof EvidenceSnapshot ? (int) $snapshot->getKey() : null,
'review_operation_run_id' => is_numeric($review->operation_run_id) ? (int) $review->operation_run_id : null,
'scope' => [
'workspace_id' => (int) $review->workspace_id,
'managed_environment_id' => (int) $review->managed_environment_id,
'environment_review_id' => (int) $review->getKey(),
],
];
}
/**
* @return list<string>
*/
private function publicationBlockers(EnvironmentReview $review): array
{
return collect($this->readinessGate->blockersForReview($review))
->map(static fn (string $blocker): string => mb_substr(trim($blocker), 0, 240))
->filter(static fn (string $blocker): bool => $blocker !== '')
->values()
->all();
}
/**
* @return array<string, array{state:string,source_record_id:?int}>
*/
private function reportDimensionStates(?EvidenceSnapshot $snapshot): array
{
$items = $snapshot instanceof EvidenceSnapshot
? $snapshot->items->keyBy('dimension_key')
: new Collection;
$states = [];
foreach (self::REQUIRED_REPORT_DIMENSIONS as $dimension) {
$item = $items->get($dimension);
$states[$dimension] = [
'state' => $item instanceof EvidenceSnapshotItem ? (string) $item->state : EvidenceCompletenessState::Missing->value,
'source_record_id' => $item instanceof EvidenceSnapshotItem && is_numeric($item->source_record_id)
? (int) $item->source_record_id
: null,
];
}
return $states;
}
/**
* @param array<string, array{state:string,source_record_id:?int}> $states
* @return list<string>
*/
private function missingReportDimensions(array $states): array
{
$missing = [];
foreach ($states as $dimension => $state) {
if (($state['state'] ?? EvidenceCompletenessState::Missing->value) !== EvidenceCompletenessState::Complete->value || ($state['source_record_id'] ?? null) === null) {
$missing[] = $dimension;
}
}
return $missing;
}
/**
* @return list<array{key:string,state:string,required:bool,source_fingerprint:?string}>
*/
private function sectionStates(EnvironmentReview $review): array
{
return $review->sections
->map(static fn ($section): array => [
'key' => (string) $section->section_key,
'state' => (string) $section->completeness_state,
'required' => (bool) $section->required,
'source_fingerprint' => is_string($section->source_snapshot_fingerprint) ? $section->source_snapshot_fingerprint : null,
])
->values()
->all();
}
}