TenantAtlas/apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php
Ahmed Darrazi acdb205e1b
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m42s
feat: customer review workspace output resolution guidance (spec 349)
Implemented the output resolution guidance for the customer review workspace and internal views. Added ReviewPackOutputResolutionGuidance, updated CustomerReviewWorkspace and EnvironmentReviewResource, and added related blade views and tests.
2026-06-03 03:31:29 +02:00

577 lines
29 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\ReviewPacks;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\ReviewPack;
use App\Support\EnvironmentReviewCompletenessState;
use App\Support\ReviewPackStatus;
use Illuminate\Support\Collection;
final class ReviewPackOutputResolutionGuidance
{
public const string STATE_CUSTOMER_SAFE_READY = 'customer_safe_ready';
public const string STATE_PUBLISHED_WITH_LIMITATIONS = 'published_with_limitations';
public const string STATE_PUBLICATION_BLOCKED = 'publication_blocked';
public const string STATE_INTERNAL_ONLY = 'internal_only';
public const string STATE_EXPORT_NOT_READY = 'export_not_ready';
public const string STATE_UNKNOWN = 'unknown';
/**
* @param array{download?:?string,review?:?string,evidence?:?string,operation?:?string} $urls
* @return array<string, mixed>
*/
public static function fromReview(EnvironmentReview $review, array $urls = []): array
{
return self::fromReadiness(self::readinessForReview($review), $urls);
}
/**
* @return array<string, mixed>
*/
public static function readinessForReview(EnvironmentReview $review): array
{
$review->loadMissing(['sections', 'evidenceSnapshot', 'currentExportReviewPack']);
$pack = $review->currentExportReviewPack;
$snapshot = $review->evidenceSnapshot;
$summary = is_array($review->summary) ? $review->summary : [];
$sections = self::outputSections($review, $pack);
$requiredSections = $sections
->filter(static fn (mixed $section): bool => (bool) $section->required)
->values();
return ReviewPackOutputReadiness::derive(
reviewStatus: (string) $review->status,
reviewCompletenessState: (string) $review->completeness_state,
evidenceCompletenessState: $snapshot instanceof EvidenceSnapshot
? (string) $snapshot->completeness_state
: EnvironmentReviewCompletenessState::Missing->value,
sectionStateCounts: self::sectionStateCounts($sections),
requiredSectionCount: $requiredSections->count(),
requiredSectionStateCounts: self::sectionStateCounts($requiredSections),
publishBlockers: is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
hasReadyExport: self::hasReadyExport($pack),
includePii: (bool) (is_array($pack?->options ?? null) ? ($pack->options['include_pii'] ?? true) : true),
protectedValuesHidden: true,
disclosurePresent: true,
);
}
/**
* @param array<string, mixed> $readiness
* @param array{download?:?string,review?:?string,evidence?:?string,operation?:?string} $urls
* @return array{
* state:string,
* label:string,
* status_label:string,
* color:string,
* severity:string,
* boundary_state:string,
* boundary_label:string,
* boundary_color:string,
* primary_reason:string,
* impact:string,
* qualified_download_label:string,
* limitation_count:int,
* limitation_summary:?string,
* action_help:?string,
* primary_action:array{label:string,url:?string,kind:string,icon:string}|null,
* secondary_actions:list<array{label:string,url:?string,kind:string,icon:string}>,
* limitations:list<array{key:string,label:string,severity:string,reason:string,action:?array{label:string,url:?string,kind:string,icon:string},details:list<string>}>,
* technical_details:array<string, string>
* }
*/
public static function fromReadiness(array $readiness, array $urls = []): array
{
$limitations = self::limitations($readiness, $urls);
$state = self::state($readiness, $limitations);
$primaryLimitation = $limitations[0] ?? null;
$primaryAction = self::primaryAction(
state: $state,
primaryLimitationKey: is_array($primaryLimitation) ? (string) $primaryLimitation['key'] : null,
urls: $urls,
);
$secondaryActions = self::secondaryActions($state, $primaryAction, $urls);
$boundaryState = self::boundaryState($state, $readiness);
return [
'state' => $state,
'label' => self::label($state),
'status_label' => self::statusLabel($state),
'color' => self::color($state),
'severity' => self::severity($state),
'boundary_state' => $boundaryState,
'boundary_label' => self::boundaryLabel($boundaryState),
'boundary_color' => self::boundaryColor($boundaryState),
'primary_reason' => is_array($primaryLimitation)
? self::shortReason((string) $primaryLimitation['key'])
: self::defaultPrimaryReason($state),
'impact' => self::impact($state),
'qualified_download_label' => self::qualifiedDownloadLabel($state),
'limitation_count' => count($limitations),
'limitation_summary' => $limitations === []
? null
: trans_choice('localization.review.output_limitations_summary', count($limitations), ['count' => count($limitations)]),
'action_help' => self::actionHelp($state),
'primary_action' => $primaryAction,
'secondary_actions' => $secondaryActions,
'limitations' => $limitations,
'technical_details' => self::technicalDetails($readiness),
];
}
private static function hasReadyExport(?ReviewPack $pack): bool
{
if (! $pack instanceof ReviewPack) {
return false;
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return false;
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return false;
}
return filled($pack->file_path) && filled($pack->file_disk);
}
/**
* @return Collection<int, mixed>
*/
private static function outputSections(EnvironmentReview $review, ?ReviewPack $pack): Collection
{
return $review->sections
->filter(static fn (mixed $section): bool => self::includesOperations($pack) || $section->section_key !== 'operations_health')
->values();
}
private static function includesOperations(?ReviewPack $pack): bool
{
return (bool) (is_array($pack?->options ?? null) ? ($pack->options['include_operations'] ?? true) : true);
}
/**
* @param Collection<int, mixed> $sections
* @return array<string, int>
*/
private static function sectionStateCounts(Collection $sections): array
{
return $sections
->countBy(static fn (mixed $section): string => (string) $section->completeness_state)
->map(static fn (int $count): int => max(0, $count))
->all();
}
/**
* @param list<array{key:string,label:string,severity:string,reason:string,action:?array{label:string,url:?string,kind:string,icon:string},details:list<string>,priority:int}> $limitations
*/
private static function state(array $readiness, array $limitations): string
{
$limitationKeys = collect($limitations)->pluck('key');
return match (true) {
$limitationKeys->contains('publish_blockers_present') => self::STATE_PUBLICATION_BLOCKED,
! (bool) ($readiness['has_ready_export'] ?? false) => self::STATE_EXPORT_NOT_READY,
(bool) ($readiness['contains_pii'] ?? false) => self::STATE_INTERNAL_ONLY,
$limitations !== [] => self::STATE_PUBLISHED_WITH_LIMITATIONS,
(string) ($readiness['readiness_state'] ?? '') === ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => self::STATE_CUSTOMER_SAFE_READY,
default => self::STATE_UNKNOWN,
};
}
/**
* @param array<string, mixed> $readiness
* @param array{download?:?string,review?:?string,evidence?:?string,operation?:?string} $urls
* @return list<array{key:string,label:string,severity:string,reason:string,action:?array{label:string,url:?string,kind:string,icon:string},details:list<string>,priority:int}>
*/
private static function limitations(array $readiness, array $urls): array
{
$sectionSummary = is_array($readiness['section_summary'] ?? null) ? $readiness['section_summary'] : [];
$sectionCounts = is_array($readiness['section_state_counts'] ?? null) ? $readiness['section_state_counts'] : [];
$limitations = collect(is_array($readiness['limitations'] ?? null) ? $readiness['limitations'] : [])
->filter(static fn (mixed $limitation): bool => is_array($limitation) && is_string($limitation['code'] ?? null))
->map(function (array $limitation) use ($readiness, $sectionSummary, $sectionCounts, $urls): ?array {
$code = (string) $limitation['code'];
return match ($code) {
'publish_blockers_present' => [
'key' => $code,
'label' => __('localization.review.publication_blocked'),
'severity' => 'danger',
'reason' => __('localization.review.publication_blocked_description'),
'action' => self::action('resolve_review_blockers', $urls['review'] ?? $urls['evidence'] ?? null),
'details' => [
__('localization.review.technical_detail_review_status_value', ['value' => (string) ($readiness['review_status'] ?? __('localization.review.unavailable'))]),
],
'priority' => 100,
],
'export_not_ready' => [
'key' => $code,
'label' => __('localization.review.export_not_ready'),
'severity' => 'warning',
'reason' => __('localization.review.export_not_ready_guidance_reason'),
'action' => self::action('review_output_limitations', $urls['review'] ?? $urls['evidence'] ?? null),
'details' => [
__('localization.review.technical_detail_ready_export_value', ['value' => __('localization.review.no')]),
],
'priority' => 90,
],
'evidence_basis_missing', 'evidence_basis_stale', 'evidence_basis_incomplete' => [
'key' => $code,
'label' => __('localization.review.evidence_basis_incomplete_guidance'),
'severity' => 'warning',
'reason' => __('localization.review.evidence_basis_incomplete_guidance_reason'),
'action' => self::action('open_evidence_basis', $urls['evidence'] ?? $urls['review'] ?? null),
'details' => [
__('localization.review.technical_detail_evidence_state_value', ['value' => (string) ($readiness['evidence_completeness_state'] ?? __('localization.review.unavailable'))]),
],
'priority' => match ($code) {
'evidence_basis_missing' => 82,
'evidence_basis_stale' => 81,
default => 80,
},
],
'required_sections_incomplete' => [
'key' => $code,
'label' => __('localization.review.required_review_sections_missing'),
'severity' => 'warning',
'reason' => trans_choice(
'localization.review.required_review_sections_missing_reason',
(int) ($sectionSummary['required_limited'] ?? 0),
['count' => (int) ($sectionSummary['required_limited'] ?? 0)],
),
'action' => self::action('review_section_limitations', $urls['review'] ?? $urls['evidence'] ?? null),
'details' => [
__('localization.review.technical_detail_section_counts_value', [
'complete' => (int) ($sectionCounts[EnvironmentReviewCompletenessState::Complete->value] ?? 0),
'partial' => (int) ($sectionCounts[EnvironmentReviewCompletenessState::Partial->value] ?? 0),
'missing' => (int) ($sectionCounts[EnvironmentReviewCompletenessState::Missing->value] ?? 0),
'stale' => (int) ($sectionCounts[EnvironmentReviewCompletenessState::Stale->value] ?? 0),
]),
],
'priority' => 70,
],
'contains_pii' => [
'key' => $code,
'label' => __('localization.review.internal_package_includes_pii'),
'severity' => 'warning',
'reason' => __('localization.review.internal_package_includes_pii_reason'),
'action' => self::action('review_pii_redaction_state', $urls['review'] ?? $urls['download'] ?? null),
'details' => [
__('localization.review.technical_detail_contains_pii_value', ['value' => __('localization.review.yes')]),
],
'priority' => 60,
],
'disclosure_missing' => [
'key' => $code,
'label' => __('localization.review.output_disclosure_missing'),
'severity' => 'warning',
'reason' => __('localization.review.output_disclosure_missing_reason'),
'action' => self::action('review_output_limitations', $urls['review'] ?? $urls['download'] ?? null),
'details' => [
__('localization.review.technical_detail_disclosure_present_value', ['value' => __('localization.review.no')]),
],
'priority' => 50,
],
default => null,
};
})
->filter()
->sortByDesc('priority')
->values()
->all();
return $limitations;
}
/**
* @param array{download?:?string,review?:?string,evidence?:?string,operation?:?string} $urls
* @return array{label:string,url:?string,kind:string,icon:string}|null
*/
private static function primaryAction(string $state, ?string $primaryLimitationKey, array $urls): ?array
{
$actionKey = match ($primaryLimitationKey) {
'publish_blockers_present' => 'resolve_review_blockers',
'evidence_basis_missing', 'evidence_basis_stale', 'evidence_basis_incomplete' => 'open_evidence_basis',
'required_sections_incomplete' => 'review_section_limitations',
'contains_pii' => 'review_pii_redaction_state',
'disclosure_missing' => 'review_output_limitations',
'export_not_ready' => 'review_output_limitations',
default => match ($state) {
self::STATE_CUSTOMER_SAFE_READY => 'download_customer_safe_review_pack',
self::STATE_INTERNAL_ONLY => 'review_pii_redaction_state',
self::STATE_EXPORT_NOT_READY => 'review_output_limitations',
self::STATE_PUBLICATION_BLOCKED => 'resolve_review_blockers',
self::STATE_PUBLISHED_WITH_LIMITATIONS => 'review_output_limitations',
default => 'review_output_limitations',
},
};
return self::action($actionKey, self::primaryActionUrl($actionKey, $urls));
}
/**
* @param array{download?:?string,review?:?string,evidence?:?string,operation?:?string} $urls
* @param array{label:string,url:?string,kind:string,icon:string}|null $primaryAction
* @return list<array{label:string,url:?string,kind:string,icon:string}>
*/
private static function secondaryActions(string $state, ?array $primaryAction, array $urls): array
{
$actions = match ($state) {
self::STATE_CUSTOMER_SAFE_READY => [
self::action('open_review', $urls['review'] ?? null),
],
self::STATE_INTERNAL_ONLY => [
self::action('download_internal_review_pack', $urls['download'] ?? null),
self::action('open_review', $urls['review'] ?? null),
],
self::STATE_EXPORT_NOT_READY => [
self::action('open_evidence_basis', $urls['evidence'] ?? null),
self::action('open_review', $urls['review'] ?? null),
],
self::STATE_PUBLICATION_BLOCKED => [
self::action('download_review_pack_with_limitations', $urls['download'] ?? null),
self::action('open_evidence_basis', $urls['evidence'] ?? null),
self::action('open_operation_proof', $urls['operation'] ?? null),
],
self::STATE_PUBLISHED_WITH_LIMITATIONS => [
self::action('download_review_pack_with_limitations', $urls['download'] ?? null),
self::action('open_review', $urls['review'] ?? null),
self::action('open_evidence_basis', $urls['evidence'] ?? null),
],
default => [
self::action('open_review', $urls['review'] ?? null),
],
};
return collect($actions)
->filter(static fn (?array $action): bool => is_array($action) && filled($action['url']))
->reject(static fn (array $action): bool => $primaryAction !== null
&& $action['label'] === $primaryAction['label']
&& $action['url'] === $primaryAction['url'])
->unique(static fn (array $action): string => $action['label'].'|'.$action['url'])
->values()
->all();
}
/**
* @param array{download?:?string,review?:?string,evidence?:?string,operation?:?string} $urls
*/
private static function primaryActionUrl(string $actionKey, array $urls): ?string
{
return match ($actionKey) {
'download_customer_safe_review_pack', 'download_internal_review_pack', 'download_review_pack_with_limitations' => $urls['download'] ?? null,
'open_evidence_basis' => $urls['evidence'] ?? $urls['review'] ?? null,
'review_section_limitations', 'resolve_review_blockers', 'review_output_limitations', 'review_pii_redaction_state', 'open_review' => $urls['review'] ?? $urls['evidence'] ?? $urls['download'] ?? null,
'open_operation_proof' => $urls['operation'] ?? null,
default => $urls['review'] ?? $urls['download'] ?? $urls['evidence'] ?? null,
};
}
/**
* @return array{label:string,url:?string,kind:string,icon:string}|null
*/
private static function action(string $actionKey, ?string $url): ?array
{
return [
'label' => match ($actionKey) {
'download_customer_safe_review_pack' => __('localization.review.download_customer_safe_review_pack'),
'download_internal_review_pack' => __('localization.review.download_internal_review_pack'),
'download_review_pack_with_limitations' => __('localization.review.download_review_pack_with_limitations'),
'open_evidence_basis' => __('localization.review.open_evidence_basis'),
'review_section_limitations' => __('localization.review.review_section_limitations'),
'review_pii_redaction_state' => __('localization.review.review_pii_redaction_state'),
'resolve_review_blockers' => __('localization.review.resolve_review_blockers'),
'open_operation_proof' => __('localization.review.open_operation_proof'),
'open_review' => __('localization.review.open_review'),
default => __('localization.review.review_output_limitations'),
},
'url' => $url,
'kind' => str_starts_with($actionKey, 'download_') ? 'download' : 'environment_link',
'icon' => str_starts_with($actionKey, 'download_')
? 'heroicon-o-arrow-down-tray'
: 'heroicon-o-arrow-top-right-on-square',
];
}
private static function label(string $state): string
{
return match ($state) {
self::STATE_PUBLICATION_BLOCKED => __('localization.review.output_not_customer_ready'),
self::STATE_CUSTOMER_SAFE_READY => __('localization.review.customer_safe_review_pack_ready'),
self::STATE_INTERNAL_ONLY => __('localization.review.internal_review_package_available'),
self::STATE_EXPORT_NOT_READY => __('localization.review.export_not_ready'),
self::STATE_PUBLISHED_WITH_LIMITATIONS => __('localization.review.published_with_limitations'),
default => __('localization.review.requires_review'),
};
}
private static function statusLabel(string $state): string
{
return match ($state) {
self::STATE_PUBLICATION_BLOCKED => __('localization.review.publication_blocked'),
self::STATE_CUSTOMER_SAFE_READY => __('localization.review.customer_safe_review_pack_ready'),
self::STATE_INTERNAL_ONLY => __('localization.review.internal_review_package_available'),
self::STATE_EXPORT_NOT_READY => __('localization.review.export_not_ready'),
self::STATE_PUBLISHED_WITH_LIMITATIONS => __('localization.review.published_with_limitations'),
default => __('localization.review.requires_review'),
};
}
private static function color(string $state): string
{
return match ($state) {
self::STATE_CUSTOMER_SAFE_READY => 'success',
self::STATE_PUBLICATION_BLOCKED => 'danger',
self::STATE_EXPORT_NOT_READY => 'gray',
default => 'warning',
};
}
private static function severity(string $state): string
{
return match ($state) {
self::STATE_CUSTOMER_SAFE_READY => 'success',
self::STATE_PUBLICATION_BLOCKED => 'danger',
self::STATE_EXPORT_NOT_READY => 'warning',
default => 'warning',
};
}
/**
* @param array<string, mixed> $readiness
*/
private static function boundaryState(string $state, array $readiness): string
{
return match ($state) {
self::STATE_CUSTOMER_SAFE_READY => 'customer_safe_ready',
self::STATE_INTERNAL_ONLY => 'internal_only',
self::STATE_EXPORT_NOT_READY => 'not_ready',
self::STATE_PUBLICATION_BLOCKED, self::STATE_PUBLISHED_WITH_LIMITATIONS => 'requires_review',
default => (string) ($readiness['customer_safe_state'] ?? 'requires_review'),
};
}
private static function boundaryLabel(string $state): string
{
return match ($state) {
'customer_safe_ready' => __('localization.review.customer_safe'),
'internal_only' => __('localization.review.internal_only'),
'not_ready' => __('localization.review.not_ready'),
default => __('localization.review.requires_review'),
};
}
private static function boundaryColor(string $state): string
{
return match ($state) {
'customer_safe_ready' => 'success',
'internal_only', 'requires_review' => 'warning',
default => 'gray',
};
}
private static function defaultPrimaryReason(string $state): string
{
return match ($state) {
self::STATE_PUBLICATION_BLOCKED => __('localization.review.publication_blocked_description'),
self::STATE_EXPORT_NOT_READY => __('localization.review.export_not_ready_guidance_reason'),
self::STATE_INTERNAL_ONLY => __('localization.review.internal_package_includes_pii_reason'),
self::STATE_CUSTOMER_SAFE_READY => __('localization.review.customer_safe_review_pack_ready_reason'),
default => __('localization.review.review_pack_with_limitations_description'),
};
}
private static function shortReason(string $limitationKey): string
{
return match ($limitationKey) {
'publish_blockers_present' => __('localization.review.publication_blocked_short_reason'),
'export_not_ready' => __('localization.review.export_not_ready_short_reason'),
'evidence_basis_missing', 'evidence_basis_stale', 'evidence_basis_incomplete' => __('localization.review.evidence_basis_incomplete_short_reason'),
'required_sections_incomplete' => __('localization.review.required_review_sections_missing_short_reason'),
'contains_pii' => __('localization.review.internal_package_includes_pii_short_reason'),
'disclosure_missing' => __('localization.review.output_disclosure_missing_short_reason'),
default => __('localization.review.review_output_limitations'),
};
}
private static function impact(string $state): string
{
return match ($state) {
self::STATE_PUBLICATION_BLOCKED => __('localization.review.publication_blocked_impact'),
self::STATE_EXPORT_NOT_READY => __('localization.review.export_not_ready_impact'),
self::STATE_INTERNAL_ONLY => __('localization.review.internal_review_package_available_impact'),
self::STATE_CUSTOMER_SAFE_READY => __('localization.review.customer_safe_review_pack_ready_impact'),
default => __('localization.review.published_with_limitations_impact'),
};
}
private static function actionHelp(string $state): ?string
{
return match ($state) {
self::STATE_PUBLICATION_BLOCKED => __('localization.review.output_action_help_publication_blocked'),
self::STATE_PUBLISHED_WITH_LIMITATIONS => __('localization.review.output_action_help_published_with_limitations'),
self::STATE_INTERNAL_ONLY => __('localization.review.output_action_help_internal_only'),
self::STATE_EXPORT_NOT_READY => __('localization.review.output_action_help_export_not_ready'),
self::STATE_CUSTOMER_SAFE_READY => __('localization.review.output_action_help_customer_safe_ready'),
default => null,
};
}
private static function qualifiedDownloadLabel(string $state): string
{
return match ($state) {
self::STATE_CUSTOMER_SAFE_READY => __('localization.review.download_customer_safe_review_pack'),
self::STATE_INTERNAL_ONLY => __('localization.review.download_internal_review_pack'),
default => __('localization.review.download_review_pack_with_limitations'),
};
}
/**
* @param array<string, mixed> $readiness
* @return array<string, string>
*/
private static function technicalDetails(array $readiness): array
{
$sectionSummary = is_array($readiness['section_summary'] ?? null) ? $readiness['section_summary'] : [];
return [
__('localization.review.review_status') => (string) ($readiness['review_status'] ?? __('localization.review.unavailable')),
__('localization.review.output_readiness') => self::statusLabel(self::state($readiness, self::limitations($readiness, []))),
__('localization.review.publication_sharing_state') => self::boundaryLabel(self::boundaryState(
self::state($readiness, self::limitations($readiness, [])),
$readiness,
)),
__('localization.review.has_ready_export') => (bool) ($readiness['has_ready_export'] ?? false)
? __('localization.review.yes')
: __('localization.review.no'),
__('localization.review.evidence_basis_state') => (string) ($readiness['evidence_completeness_state'] ?? __('localization.review.unavailable')),
__('localization.review.section_completeness') => __('localization.review.technical_detail_required_sections_value', [
'complete' => (int) ($sectionSummary['required_complete'] ?? 0),
'total' => (int) ($sectionSummary['required_total'] ?? 0),
'limited' => (int) ($sectionSummary['required_limited'] ?? 0),
]),
__('localization.review.pii_state') => (bool) ($readiness['contains_pii'] ?? false)
? __('localization.review.yes')
: __('localization.review.no'),
__('localization.review.protected_values') => (bool) ($readiness['protected_values_hidden'] ?? true)
? __('localization.review.protected_values_hidden')
: __('localization.review.unavailable'),
__('localization.review.disclosure') => (bool) ($readiness['disclosure_present'] ?? false)
? __('localization.review.disclosure_present')
: __('localization.review.no'),
];
}
}