TenantAtlas/apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewComposer.php
ahmido 9cd06e8b66 feat: review pack pdf and html renderer v1 (spec 356) (#427)
Implemented the first version of the PDF and HTML renderer for review packs. Added ReviewPackRenderedReportController and related blade views to render reports. Updated EnvironmentReviewResource, ReviewPackResource, ReviewPackService, and routing. Added new tests for the renderer and download actions, and updated UI documentation.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #427
2026-06-05 20:39:13 +00:00

467 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\EnvironmentReviews;
use App\Models\EvidenceSnapshot;
use App\Models\EnvironmentReview;
use App\Support\EnvironmentReviewStatus;
final class EnvironmentReviewComposer
{
public function __construct(
private readonly EnvironmentReviewFingerprint $fingerprint,
private readonly EnvironmentReviewSectionFactory $sectionFactory,
private readonly EnvironmentReviewReadinessGate $readinessGate,
) {}
/**
* @return array{
* fingerprint: string,
* completeness_state: string,
* status: string,
* summary: array<string, mixed>,
* sections: list<array<string, mixed>>
* }
*/
public function compose(EvidenceSnapshot $snapshot, ?EnvironmentReview $review = null): array
{
$tenant = $snapshot->tenant;
if ($tenant === null) {
throw new \RuntimeException('Evidence snapshot tenant is required for review composition.');
}
$sections = $this->sectionFactory->make($snapshot);
$blockers = $this->readinessGate->blockersForSections($sections);
$sectionStateCounts = $this->readinessGate->sectionStateCounts($sections);
$completeness = $this->readinessGate->completenessForSections($sections);
$status = $this->readinessGate->statusForSections($sections);
$executiveSummarySection = collect($sections)
->firstWhere('section_key', 'executive_summary');
$controlInterpretationSection = collect($sections)
->firstWhere('section_key', 'control_interpretation');
$openRisksSection = collect($sections)
->firstWhere('section_key', 'open_risks');
$acceptedRisksSection = collect($sections)
->firstWhere('section_key', 'accepted_risks');
$operationsSection = collect($sections)
->firstWhere('section_key', 'operations_health');
if ($review instanceof EnvironmentReview && $review->isPublished()) {
$status = EnvironmentReviewStatus::Published;
}
return [
'fingerprint' => $this->fingerprint->forSnapshot($tenant, $snapshot),
'completeness_state' => $completeness->value,
'status' => $status->value,
'summary' => [
'evidence_basis' => [
'snapshot_id' => (int) $snapshot->getKey(),
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
'snapshot_completeness_state' => (string) $snapshot->completeness_state,
'snapshot_generated_at' => $snapshot->generated_at?->toIso8601String(),
],
'section_count' => count($sections),
'section_state_counts' => $sectionStateCounts,
'publish_blockers' => $blockers,
'has_ready_export' => false,
'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0),
'finding_outcomes' => is_array(data_get($sections, '0.summary_payload.finding_outcomes'))
? data_get($sections, '0.summary_payload.finding_outcomes')
: [],
'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets'))
? data_get($sections, '0.summary_payload.finding_report_buckets')
: [],
'canonical_controls' => is_array(data_get($sections, '0.summary_payload.canonical_controls'))
? data_get($sections, '0.summary_payload.canonical_controls')
: [],
'control_interpretation' => is_array(data_get($controlInterpretationSection, 'summary_payload'))
? array_merge(
data_get($controlInterpretationSection, 'summary_payload'),
[
'controls' => is_array(data_get($controlInterpretationSection, 'render_payload.entries'))
? data_get($controlInterpretationSection, 'render_payload.entries')
: [],
],
)
: [],
'report_count' => 2,
'operation_count' => (int) data_get($operationsSection, 'summary_payload.operation_count', 0),
'highlights' => data_get($sections, '0.render_payload.highlights', []),
'recommended_next_actions' => data_get($sections, '0.render_payload.next_actions', []),
'governance_package' => $this->governancePackageSummary(
snapshot: $snapshot,
executiveSummarySection: is_array($executiveSummarySection) ? $executiveSummarySection : [],
controlInterpretationSection: is_array($controlInterpretationSection) ? $controlInterpretationSection : [],
openRisksSection: is_array($openRisksSection) ? $openRisksSection : [],
acceptedRisksSection: is_array($acceptedRisksSection) ? $acceptedRisksSection : [],
),
'last_composed_at' => now()->toIso8601String(),
],
'sections' => $sections,
];
}
/**
* @param array<string, mixed> $executiveSummarySection
* @param array<string, mixed> $controlInterpretationSection
* @param array<string, mixed> $openRisksSection
* @param array<string, mixed> $acceptedRisksSection
* @return array<string, mixed>
*/
private function governancePackageSummary(
EvidenceSnapshot $snapshot,
array $executiveSummarySection,
array $controlInterpretationSection,
array $openRisksSection,
array $acceptedRisksSection,
): array {
$executiveSummaryPayload = is_array($executiveSummarySection['summary_payload'] ?? null)
? $executiveSummarySection['summary_payload']
: [];
$executiveRenderPayload = is_array($executiveSummarySection['render_payload'] ?? null)
? $executiveSummarySection['render_payload']
: [];
$controlInterpretationSummary = is_array($controlInterpretationSection['summary_payload'] ?? null)
? $controlInterpretationSection['summary_payload']
: [];
$openRiskEntries = collect(data_get($openRisksSection, 'render_payload.entries', []))
->filter(static fn (mixed $entry): bool => is_array($entry))
->take(3)
->map(fn (array $entry): array => $this->packageFindingEntry($entry))
->values()
->all();
$acceptedRiskEntries = collect(data_get($acceptedRisksSection, 'render_payload.entries', []))
->filter(static fn (mixed $entry): bool => is_array($entry))
->map(fn (array $entry): array => $this->packageAcceptedRiskEntry($entry))
->values();
$governanceDecisionEntries = $acceptedRiskEntries
->filter(fn (array $entry): bool => $this->requiresGovernanceDecisionFollowUp($entry))
->values();
$stableAcceptedRiskEntries = $acceptedRiskEntries
->reject(fn (array $entry): bool => $this->requiresGovernanceDecisionFollowUp($entry))
->values();
$governanceDecisions = $governanceDecisionEntries
->map(fn (array $entry): array => $this->packageGovernanceDecisionEntry($entry))
->values()
->all();
$decisionSummary = $this->packageDecisionSummary(
snapshot: $snapshot,
acceptedRisksSection: $acceptedRisksSection,
governanceDecisions: $governanceDecisions,
);
return [
'delivery_artifact_family' => 'review_pack',
'interpretation_version' => is_string($controlInterpretationSummary['version_key'] ?? null)
? $controlInterpretationSummary['version_key']
: null,
'executive_summary' => $this->governancePackageExecutiveSummary(
executiveSummaryPayload: $executiveSummaryPayload,
executiveRenderPayload: $executiveRenderPayload,
controlInterpretationSummary: $controlInterpretationSummary,
acceptedRiskCount: $acceptedRiskEntries->count(),
),
'top_findings' => $openRiskEntries,
'accepted_risks' => $stableAcceptedRiskEntries->all(),
'governance_decisions' => $governanceDecisions,
'decision_summary' => $decisionSummary,
'evidence_basis_summary' => $this->governancePackageEvidenceBasisSummary(
snapshot: $snapshot,
controlInterpretationSummary: $controlInterpretationSummary,
),
'supporting_artifact_links' => [
[
'artifact_family' => 'evidence_snapshot',
'artifact_key' => 'evidence_snapshot:'.$snapshot->getKey(),
'purpose' => 'evidence_basis',
],
[
'artifact_family' => 'review_pack',
'artifact_key' => 'review_pack:current_export',
'purpose' => 'stakeholder_delivery',
],
],
];
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function packageFindingEntry(array $entry): array
{
return [
'finding_id' => is_numeric($entry['id'] ?? null) ? (int) $entry['id'] : null,
'title' => $this->entryTitle($entry, 'Open finding'),
'severity' => is_string($entry['severity'] ?? null) ? $entry['severity'] : 'unknown',
'status' => is_string($entry['status'] ?? null) ? $entry['status'] : 'unknown',
'summary' => $this->entrySummary($entry, 'This finding remains open in the released review and should be discussed in stakeholder delivery.'),
];
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function packageAcceptedRiskEntry(array $entry): array
{
return [
'finding_id' => is_numeric($entry['id'] ?? null) ? (int) $entry['id'] : null,
'title' => $this->entryTitle($entry, 'Accepted risk'),
'governance_state' => is_string($entry['governance_state'] ?? null) ? $entry['governance_state'] : 'unknown',
'summary' => $this->entrySummary($entry, 'This accepted-risk entry qualifies the current governance position for stakeholder delivery.'),
'customer_safe_summary' => $this->customerSafeSummary($entry),
'owner_label' => $this->ownerLabel($entry),
'status' => is_string($entry['exception_status'] ?? null) ? $entry['exception_status'] : null,
'review_due_at' => is_string($entry['review_due_at'] ?? null) ? $entry['review_due_at'] : null,
'expires_at' => is_string($entry['expires_at'] ?? null) ? $entry['expires_at'] : null,
];
}
/**
* @param array<string, mixed> $entry
*/
private function requiresGovernanceDecisionFollowUp(array $entry): bool
{
return in_array((string) ($entry['governance_state'] ?? ''), [
'expired_exception',
'revoked_exception',
'risk_accepted_without_valid_exception',
], true);
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function packageGovernanceDecisionEntry(array $entry): array
{
$governanceState = (string) ($entry['governance_state'] ?? 'unknown');
return [
'finding_id' => $entry['finding_id'] ?? null,
'title' => $entry['title'] ?? 'Governance decision',
'governance_state' => $governanceState,
'awareness_reason' => $this->governanceDecisionAwarenessReason($governanceState),
'summary' => match ($governanceState) {
'expired_exception' => 'The accepted-risk exception has expired and needs follow-up before stakeholder delivery.',
'revoked_exception' => 'The accepted-risk exception was revoked and needs follow-up before stakeholder delivery.',
'risk_accepted_without_valid_exception' => 'The accepted-risk entry has no currently valid exception basis and needs follow-up before stakeholder delivery.',
default => 'This governance decision needs follow-up before stakeholder delivery.',
},
'next_action' => $this->governanceDecisionNextAction($governanceState),
'evidence_basis' => 'Included in the anchored released-review evidence basis.',
];
}
/**
* @param array<string, mixed> $acceptedRisksSection
* @param list<array<string, mixed>> $governanceDecisions
* @return array<string, mixed>
*/
private function packageDecisionSummary(
EvidenceSnapshot $snapshot,
array $acceptedRisksSection,
array $governanceDecisions,
): array {
$totalCount = count($governanceDecisions);
$evidenceState = is_string($acceptedRisksSection['completeness_state'] ?? null)
? (string) $acceptedRisksSection['completeness_state']
: (string) $snapshot->completeness_state;
$decisionDataState = $totalCount > 0 || $this->decisionEvidenceIsAvailable($evidenceState)
? 'available'
: 'incomplete';
$status = match (true) {
$totalCount > 0 => 'requires_awareness',
$decisionDataState === 'incomplete' => 'unavailable',
default => 'none',
};
return [
'customer_safe' => true,
'status' => $status,
'decision_data_state' => $decisionDataState,
'evidence_state' => $evidenceState,
'total_count' => $totalCount,
'requires_awareness' => $totalCount > 0,
'summary' => $this->decisionSummaryText($status, $totalCount),
'empty_state' => $this->decisionSummaryEmptyState($status),
'next_action' => $this->decisionSummaryNextAction($status),
'evidence_basis' => sprintf(
'Anchored to evidence snapshot #%d with %s decision-evidence completeness.',
(int) $snapshot->getKey(),
$evidenceState,
),
'source_section_state' => is_string($acceptedRisksSection['completeness_state'] ?? null)
? $acceptedRisksSection['completeness_state']
: null,
'entries' => $governanceDecisions,
];
}
private function decisionEvidenceIsAvailable(string $evidenceState): bool
{
return in_array($evidenceState, ['complete'], true);
}
private function decisionSummaryText(string $status, int $totalCount): string
{
return match ($status) {
'requires_awareness' => sprintf(
'%d governance decision%s require%s customer awareness before relying on this released review.',
$totalCount,
$totalCount === 1 ? '' : 's',
$totalCount === 1 ? 's' : '',
),
'unavailable' => 'Decision evidence is incomplete for this released review; no customer-aware decisions can be confirmed from the current evidence basis.',
default => 'No governance decisions require customer awareness in this released review.',
};
}
private function decisionSummaryEmptyState(string $status): string
{
return match ($status) {
'unavailable' => 'Decision evidence is incomplete in the current released-review basis.',
'requires_awareness' => '',
default => 'No governance decisions require awareness in this released review.',
};
}
private function decisionSummaryNextAction(string $status): string
{
return match ($status) {
'requires_awareness' => 'Review the accepted-risk decision basis before customer delivery.',
'unavailable' => 'Open the review evidence before treating the decision register summary as complete.',
default => 'No customer action is needed for Decision Register follow-up from this review.',
};
}
private function governanceDecisionAwarenessReason(string $governanceState): string
{
return match ($governanceState) {
'expired_exception' => 'The accepted-risk approval has expired and needs customer awareness before the review is relied on.',
'revoked_exception' => 'The accepted-risk approval was revoked and needs customer awareness before the review is relied on.',
'risk_accepted_without_valid_exception' => 'The accepted risk has no valid governance backing in the released review evidence.',
default => 'This accepted-risk governance decision needs customer awareness before the review is relied on.',
};
}
private function governanceDecisionNextAction(string $governanceState): string
{
return match ($governanceState) {
'expired_exception', 'revoked_exception', 'risk_accepted_without_valid_exception' => 'Confirm whether this accepted risk should be renewed, remediated, or removed before relying on the review.',
default => 'Confirm the accepted-risk decision before relying on the review.',
};
}
/**
* @param array<string, mixed> $executiveSummaryPayload
* @param array<string, mixed> $executiveRenderPayload
* @param array<string, mixed> $controlInterpretationSummary
*/
private function governancePackageExecutiveSummary(
array $executiveSummaryPayload,
array $executiveRenderPayload,
array $controlInterpretationSummary,
int $acceptedRiskCount,
): string {
$highlights = collect($executiveRenderPayload['highlights'] ?? [])
->filter(static fn (mixed $highlight): bool => is_string($highlight) && trim($highlight) !== '')
->values();
if ($highlights->isNotEmpty()) {
return (string) $highlights->first();
}
return sprintf(
'This released review summarizes %d mapped control(s), %d open risk(s), and %d accepted-risk item(s) from the anchored evidence basis.',
(int) ($controlInterpretationSummary['mapped_control_count'] ?? 0),
(int) ($executiveSummaryPayload['open_risk_count'] ?? 0),
$acceptedRiskCount,
);
}
/**
* @param array<string, mixed> $controlInterpretationSummary
*/
private function governancePackageEvidenceBasisSummary(EvidenceSnapshot $snapshot, array $controlInterpretationSummary): string
{
return sprintf(
'Anchored to evidence snapshot #%d with %s completeness and %d mapped control(s).',
(int) $snapshot->getKey(),
(string) $snapshot->completeness_state,
(int) ($controlInterpretationSummary['mapped_control_count'] ?? 0),
);
}
/**
* @param array<string, mixed> $entry
*/
private function entryTitle(array $entry, string $fallback): string
{
foreach (['title', 'name', 'finding_title'] as $key) {
$value = $entry[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
return $fallback;
}
/**
* @param array<string, mixed> $entry
*/
private function entrySummary(array $entry, string $fallback): string
{
foreach (['customer_summary', 'summary', 'request_reason'] as $key) {
$value = $entry[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
return $fallback;
}
/**
* @param array<string, mixed> $entry
*/
private function customerSafeSummary(array $entry): ?string
{
foreach (['customer_safe_summary', 'customer_summary'] as $key) {
$value = $entry[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
return null;
}
/**
* @param array<string, mixed> $entry
*/
private function ownerLabel(array $entry): ?string
{
$owner = $entry['owner'] ?? null;
if (is_array($owner)) {
$name = $owner['name'] ?? null;
if (is_string($name) && trim($name) !== '') {
return $name;
}
}
return null;
}
}