TenantAtlas/apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php
ahmido b7907bd69d feat: add report profile and disclosure policy to rendered review reports (#428)
Implementing report profiles and disclosure policy as per spec 357.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #428
2026-06-06 09:41:19 +00:00

584 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;
$packSummary = is_array($pack?->summary ?? null) ? $pack->summary : [];
$controlInterpretation = is_array($packSummary['control_interpretation'] ?? null)
? $packSummary['control_interpretation']
: [];
$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();
$includePii = (bool) (is_array($pack?->options ?? null) ? ($pack->options['include_pii'] ?? true) : true);
$nonCertificationDisclosure = trim((string) ($controlInterpretation['non_certification_disclosure'] ?? ''));
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: $includePii,
protectedValuesHidden: ! $includePii,
disclosurePresent: $nonCertificationDisclosure !== '',
);
}
/**
* @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{key:string,label:string,url:?string,kind:string,icon:string}|null,
* secondary_actions:list<array{key:string,label:string,url:?string,kind:string,icon:string}>,
* limitations:list<array{key:string,label:string,severity:string,reason:string,action:?array{key:string,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{key:string,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{key:string,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{key:string,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{key:string,label:string,url:?string,kind:string,icon:string}|null $primaryAction
* @return list<array{key:string,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{key:string,label:string,url:?string,kind:string,icon:string}|null
*/
private static function action(string $actionKey, ?string $url): ?array
{
return [
'key' => $actionKey,
'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'),
];
}
}