TenantAtlas/apps/platform/app/Http/Controllers/ReviewPackRenderedReportController.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

585 lines
27 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Filament\Resources\ReviewPackResource;
use App\Models\EnvironmentReview;
use App\Models\EnvironmentReviewSection;
use App\Models\ManagedEnvironment;
use App\Models\ReviewPack;
use App\Models\User;
use App\Models\Workspace;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
use App\Support\ReviewPackStatus;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ReviewPackRenderedReportController extends Controller
{
public function __invoke(Request $request, ReviewPack $reviewPack): Response
{
$reviewPack->loadMissing([
'tenant.workspace',
'environmentReview.evidenceSnapshot',
'environmentReview.currentExportReviewPack',
'environmentReview.sections',
]);
$user = $request->user();
$tenant = $reviewPack->tenant;
$review = $reviewPack->environmentReview;
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment || ! $review instanceof EnvironmentReview) {
throw new NotFoundHttpException;
}
if (! $user->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
abort(403);
}
if ($reviewPack->status !== ReviewPackStatus::Ready->value) {
throw new NotFoundHttpException;
}
if ($reviewPack->expires_at !== null && $reviewPack->expires_at->isPast()) {
throw new NotFoundHttpException;
}
if ((int) $review->managed_environment_id !== (int) $tenant->getKey()) {
throw new NotFoundHttpException;
}
if ((int) ($review->current_export_review_pack_id ?? 0) !== (int) $reviewPack->getKey()) {
throw new NotFoundHttpException;
}
return response()->view('review-packs.rendered-report', [
'report' => $this->reportState($request, $reviewPack, $review, $tenant),
]);
}
/**
* @return array<string, mixed>
*/
private function reportState(
Request $request,
ReviewPack $reviewPack,
EnvironmentReview $review,
ManagedEnvironment $tenant,
): array {
$summary = is_array($reviewPack->summary) ? $reviewPack->summary : [];
$governancePackage = is_array($summary['governance_package'] ?? null) ? $summary['governance_package'] : [];
$controlInterpretation = is_array($summary['control_interpretation'] ?? null) ? $summary['control_interpretation'] : [];
$downloadUrl = $this->downloadUrl($request, $reviewPack, $review);
$reviewUrl = $this->reviewUrl($request, $review, $tenant);
$reviewPackUrl = $this->reviewPackUrl($request, $reviewPack, $tenant);
$readiness = ReviewPackOutputResolutionGuidance::readinessForReview($review);
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness, [
'download' => $downloadUrl,
'review' => $reviewUrl,
]);
$decisionSummary = is_array($governancePackage['decision_summary'] ?? null)
? $governancePackage['decision_summary']
: [];
$state = (string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN);
$limitations = $this->managementLimitations(is_array($guidance['limitations'] ?? null) ? $guidance['limitations'] : []);
$evidenceBasis = $this->evidenceBasisState($review);
return [
'title' => __('localization.review.rendered_report'),
'guidance' => $guidance,
'state' => $state,
'hero' => $this->heroState($state, $guidance),
'branding' => $this->brandingState($tenant),
'tenant_name' => $tenant->name,
'review_id' => (int) $review->getKey(),
'pack_id' => (int) $reviewPack->getKey(),
'review_status' => (string) $review->status,
'review_completeness_state' => (string) $review->completeness_state,
'review_status_label' => Str::headline((string) $review->status),
'review_completeness_label' => Str::headline((string) $review->completeness_state),
'generated_at' => $reviewPack->generated_at,
'published_at' => $review->published_at,
'download_url' => $downloadUrl,
'download_label' => $this->downloadLabel($state),
'review_url' => $reviewUrl,
'review_pack_url' => $reviewPackUrl,
'html_only' => true,
'executive_summary' => $this->plainText(
$governancePackage['executive_summary'] ?? null,
__('localization.review.rendered_report_summary_fallback'),
),
'management_summary' => $this->managementSummary(
state: $state,
guidance: $guidance,
limitations: $limitations,
storedExecutiveSummary: $governancePackage['executive_summary'] ?? null,
),
'evidence_basis' => $evidenceBasis,
'evidence_basis_summary' => $evidenceBasis['description'],
'limitations' => $limitations,
'top_findings' => is_array($governancePackage['top_findings'] ?? null) ? $governancePackage['top_findings'] : [],
'accepted_risks' => $this->acceptedRiskItems(
is_array($governancePackage['accepted_risks'] ?? null) ? $governancePackage['accepted_risks'] : [],
$state !== ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY,
),
'decision_summary' => $decisionSummary,
'governance_decisions' => is_array($decisionSummary['entries'] ?? null)
? $decisionSummary['entries']
: (is_array($governancePackage['governance_decisions'] ?? null) ? $governancePackage['governance_decisions'] : []),
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'non_certification_disclosure' => $this->plainText(
$controlInterpretation['non_certification_disclosure'] ?? null,
__('localization.review.non_certification_disclosure_text'),
),
'section_appendix' => $this->sectionAppendix($reviewPack, $review),
'technical_details' => is_array($guidance['technical_details'] ?? null) ? $guidance['technical_details'] : [],
'entrypoint_file' => (string) data_get($summary, 'delivery_bundle.executive_entrypoint_file', ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME),
'appendix_files' => is_array(data_get($summary, 'delivery_bundle.appendix_files')) ? data_get($summary, 'delivery_bundle.appendix_files') : [],
];
}
/**
* @return array{prepared_by:string,prepared_for:string,generated_by:string}
*/
private function brandingState(ManagedEnvironment $tenant): array
{
$workspace = $tenant->workspace;
return [
'prepared_by' => $workspace instanceof Workspace && filled($workspace->name)
? (string) $workspace->name
: 'TenantPilot',
'prepared_for' => filled($tenant->name) ? (string) $tenant->name : __('localization.review.tenant'),
'generated_by' => 'TenantPilot',
];
}
/**
* @param array<string, mixed> $guidance
* @return array{title:string,badge:string,summary:string,warning:?string,color:string,customer_safe:bool}
*/
private function heroState(string $state, array $guidance): array
{
$customerSafe = $state === ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY;
return [
'title' => match ($state) {
ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => __('localization.review.report_state_customer_safe_ready'),
ReviewPackOutputResolutionGuidance::STATE_PUBLISHED_WITH_LIMITATIONS => __('localization.review.report_state_with_limitations'),
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY => __('localization.review.report_state_internal_with_limitations'),
default => __('localization.review.report_state_not_customer_ready'),
},
'badge' => $customerSafe
? __('localization.review.customer_safe')
: (string) ($guidance['boundary_label'] ?? __('localization.review.requires_review')),
'summary' => match ($state) {
ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => __('localization.review.report_state_customer_safe_ready_summary'),
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY => __('localization.review.report_state_internal_summary'),
ReviewPackOutputResolutionGuidance::STATE_PUBLISHED_WITH_LIMITATIONS => __('localization.review.report_state_limitations_summary'),
default => __('localization.review.report_state_not_ready_summary'),
},
'warning' => $customerSafe ? null : __('localization.review.report_external_sharing_warning'),
'color' => $customerSafe ? 'success' : ($state === ReviewPackOutputResolutionGuidance::STATE_PUBLICATION_BLOCKED ? 'danger' : 'warning'),
'customer_safe' => $customerSafe,
];
}
/**
* @param array<string, mixed> $guidance
* @param list<array<string, string>> $limitations
* @return array{overall_state:string,reason:string,impact:string,next_action:string,top_limitations:list<string>}
*/
private function managementSummary(string $state, array $guidance, array $limitations, mixed $storedExecutiveSummary): array
{
$topLimitations = collect($limitations)
->pluck('summary')
->filter(static fn (mixed $summary): bool => is_string($summary) && trim($summary) !== '')
->take(3)
->values()
->all();
return [
'overall_state' => (string) ($this->heroState($state, $guidance)['title']),
'reason' => $this->managementReason($state, $guidance, $storedExecutiveSummary),
'impact' => $this->plainText($guidance['impact'] ?? null, __('localization.review.report_summary_default_impact')),
'next_action' => $this->managementNextAction($state, $limitations),
'top_limitations' => $topLimitations,
];
}
/**
* @param array<string, mixed> $guidance
*/
private function managementReason(string $state, array $guidance, mixed $storedExecutiveSummary): string
{
if ($state === ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY) {
return $this->plainText($storedExecutiveSummary, __('localization.review.report_summary_ready_reason'));
}
return $this->plainText($guidance['primary_reason'] ?? null, __('localization.review.report_summary_limited_reason'));
}
/**
* @param list<array<string, string>> $limitations
*/
private function managementNextAction(string $state, array $limitations): string
{
$firstKey = $limitations[0]['key'] ?? null;
return match (true) {
$state === ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => __('localization.review.report_next_action_ready'),
$state === ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY => __('localization.review.report_next_action_internal'),
$firstKey === 'evidence_basis_missing',
$firstKey === 'evidence_basis_stale',
$firstKey === 'evidence_basis_incomplete' => __('localization.review.report_next_action_evidence'),
$firstKey === 'required_sections_incomplete' => __('localization.review.report_next_action_sections'),
$firstKey === 'publish_blockers_present' => __('localization.review.report_next_action_blockers'),
default => __('localization.review.report_next_action_limited'),
};
}
/**
* @param list<array<string, mixed>> $limitations
* @return list<array{key:string,title:string,summary:string,next_action:string,severity:string}>
*/
private function managementLimitations(array $limitations): array
{
return collect($limitations)
->map(function (array $limitation): array {
$key = (string) ($limitation['key'] ?? 'unknown');
return [
'key' => $key,
'title' => $this->plainText($limitation['label'] ?? null, __('localization.review.output_limitations')),
'summary' => match ($key) {
'evidence_basis_missing',
'evidence_basis_stale',
'evidence_basis_incomplete' => __('localization.review.report_limitation_evidence_summary'),
'required_sections_incomplete' => __('localization.review.report_limitation_sections_summary'),
'contains_pii' => __('localization.review.report_limitation_pii_summary'),
'publish_blockers_present' => __('localization.review.report_limitation_blockers_summary'),
'export_not_ready' => __('localization.review.report_limitation_export_summary'),
'disclosure_missing' => __('localization.review.report_limitation_disclosure_summary'),
default => $this->plainText($limitation['reason'] ?? null, __('localization.review.report_limitation_default_summary')),
},
'next_action' => match ($key) {
'evidence_basis_missing',
'evidence_basis_stale',
'evidence_basis_incomplete' => __('localization.review.report_next_action_evidence'),
'required_sections_incomplete' => __('localization.review.report_next_action_sections'),
'contains_pii' => __('localization.review.report_next_action_internal'),
'publish_blockers_present' => __('localization.review.report_next_action_blockers'),
default => __('localization.review.report_next_action_limited'),
},
'severity' => (string) ($limitation['severity'] ?? 'warning'),
];
})
->values()
->all();
}
/**
* @return array{label:string,description:string,operator_action:string,snapshot_label:string}
*/
private function evidenceBasisState(EnvironmentReview $review): array
{
$snapshot = $review->evidenceSnapshot;
$state = (string) ($snapshot?->completeness_state ?? 'missing');
$snapshotLabel = $snapshot instanceof \App\Models\EvidenceSnapshot
? __('localization.review.evidence_snapshot_number', ['id' => (int) $snapshot->getKey()])
: __('localization.review.evidence_snapshot_missing');
return [
'label' => Str::headline($state),
'description' => match ($state) {
'complete' => __('localization.review.report_evidence_complete_description', ['snapshot' => $snapshotLabel]),
'stale' => __('localization.review.report_evidence_stale_description', ['snapshot' => $snapshotLabel]),
'partial' => __('localization.review.report_evidence_partial_description', ['snapshot' => $snapshotLabel]),
default => __('localization.review.report_evidence_missing_description'),
},
'operator_action' => match ($state) {
'complete' => __('localization.review.report_evidence_complete_action'),
default => __('localization.review.report_next_action_evidence'),
},
'snapshot_label' => $snapshotLabel,
];
}
/**
* @param list<array<string, mixed>> $acceptedRisks
* @return list<array{title:string,status:string,summary:?string,limitation:?string,owner:?string,review_state:string}>
*/
private function acceptedRiskItems(array $acceptedRisks, bool $showOwner): array
{
return collect($acceptedRisks)
->filter(static fn (mixed $risk): bool => is_array($risk))
->map(function (array $risk) use ($showOwner): array {
$customerSafeSummary = $this->plainText(
$risk['customer_safe_summary'] ?? ($risk['customer_summary'] ?? null),
'',
);
$status = $this->acceptedRiskStatusLabel((string) ($risk['governance_state'] ?? ($risk['status'] ?? 'unknown')));
$owner = $showOwner
? $this->plainText($risk['owner_label'] ?? data_get($risk, 'owner.name'), '')
: '';
return [
'title' => $this->plainText($risk['title'] ?? null, __('localization.review.accepted_risks')),
'status' => $status,
'summary' => $customerSafeSummary !== '' ? $customerSafeSummary : null,
'limitation' => $customerSafeSummary === '' ? __('localization.review.accepted_risk_customer_safe_summary_missing') : null,
'owner' => $owner !== '' ? $owner : null,
'review_state' => $this->acceptedRiskReviewState($risk),
];
})
->values()
->all();
}
private function acceptedRiskStatusLabel(string $state): string
{
return match ($state) {
'valid_exception' => __('localization.review.accepted_risk_state_current'),
'expiring_exception' => __('localization.review.accepted_risks_expiring_soon'),
'expired_exception' => __('localization.review.accepted_risks_expired'),
'revoked_exception', 'rejected_exception' => __('localization.review.accepted_risks_needs_review'),
'risk_accepted_without_valid_exception' => __('localization.review.accepted_risks_needs_review'),
default => Str::headline($state),
};
}
/**
* @param array<string, mixed> $risk
*/
private function acceptedRiskReviewState(array $risk): string
{
$reviewDueAt = $this->plainText($risk['review_due_at'] ?? null, '');
$expiresAt = $this->plainText($risk['expires_at'] ?? null, '');
if ($reviewDueAt !== '') {
return __('localization.review.accepted_risk_review_due_on', ['date' => $reviewDueAt]);
}
if ($expiresAt !== '') {
return __('localization.review.accepted_risk_expires_on', ['date' => $expiresAt]);
}
return __('localization.review.review_date_not_recorded');
}
private function downloadLabel(string $state): string
{
return match ($state) {
ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => __('localization.review.download_customer_safe_review_pack'),
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY => __('localization.review.download_internal_review_pack'),
default => __('localization.review.download_review_pack_with_limitations'),
};
}
/**
* @return list<array<string, mixed>>
*/
private function sectionAppendix(ReviewPack $reviewPack, EnvironmentReview $review): array
{
$includeOperations = (bool) (($reviewPack->options ?? [])['include_operations'] ?? true);
return $review->sections
->filter(fn (EnvironmentReviewSection $section): bool => $includeOperations || $section->section_key !== 'operations_health')
->sortBy('sort_order')
->values()
->map(function (EnvironmentReviewSection $section): array {
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
return [
'title' => (string) $section->title,
'completeness_label' => Str::headline((string) $section->completeness_state),
'summary_items' => collect($summaryPayload)
->map(function (mixed $value, string $key): ?array {
if (is_array($value) || $value === null || $value === '') {
return null;
}
return [
'label' => Str::headline($key),
'value' => (string) $value,
];
})
->filter()
->take(6)
->values()
->all(),
'highlights' => collect($renderPayload['highlights'] ?? [])
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->take(5)
->values()
->all(),
'entries' => $this->sectionEntries($renderPayload, $section->isControlInterpretation()),
'next_actions' => collect($renderPayload['next_actions'] ?? [])
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->take(4)
->values()
->all(),
'disclosure' => is_string($renderPayload['disclosure'] ?? null) ? $renderPayload['disclosure'] : null,
];
})
->all();
}
/**
* @param array<string, mixed> $renderPayload
* @return list<array{title:string,summary:?string}>
*/
private function sectionEntries(array $renderPayload, bool $isControlInterpretation): array
{
return collect($renderPayload['entries'] ?? [])
->filter(fn (mixed $entry): bool => is_array($entry))
->take($isControlInterpretation ? 3 : 4)
->map(function (array $entry) use ($isControlInterpretation): array {
if ($isControlInterpretation) {
$basisItems = collect($entry['evidence_basis_items'] ?? [])
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->take(2)
->values()
->all();
$summary = $this->plainText($entry['explanation_text'] ?? null, '');
if ($summary === '' && $basisItems !== []) {
$summary = implode(' ', $basisItems);
}
return [
'title' => $this->plainText($entry['control_name'] ?? ($entry['title'] ?? null), __('localization.review.control')),
'summary' => $summary !== '' ? $summary : null,
];
}
$detailParts = collect([
$entry['summary'] ?? null,
isset($entry['severity']) ? Str::headline((string) $entry['severity']) : null,
isset($entry['status']) ? Str::headline((string) $entry['status']) : null,
isset($entry['outcome']) ? Str::headline((string) $entry['outcome']) : null,
])
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->values()
->all();
return [
'title' => $this->plainText($entry['title'] ?? ($entry['displayName'] ?? null), __('localization.review.entry')),
'summary' => $detailParts === [] ? null : implode(' · ', $detailParts),
];
})
->values()
->all();
}
private function downloadUrl(Request $request, ReviewPack $reviewPack, EnvironmentReview $review): ?string
{
if (! filled($reviewPack->file_path) || ! filled($reviewPack->file_disk)) {
return null;
}
return app(ReviewPackService::class)->generateDownloadUrl(
$reviewPack,
$this->routeParameters($request, $review),
);
}
private function reviewUrl(Request $request, EnvironmentReview $review, ManagedEnvironment $tenant): string
{
$url = EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review], $tenant);
if (! $request->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY)) {
return $url;
}
return $this->appendQuery($url, [
CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1,
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'tenant_filter_id' => $request->query('tenant_filter_id'),
]);
}
private function reviewPackUrl(Request $request, ReviewPack $reviewPack, ManagedEnvironment $tenant): string
{
$url = ReviewPackResource::getUrl('view', ['record' => $reviewPack], tenant: $tenant, panel: 'admin');
if ((string) $request->query('source_surface') !== CustomerReviewWorkspace::SOURCE_SURFACE) {
return $url;
}
return $this->appendQuery($url, [
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'tenant_filter_id' => $request->query('tenant_filter_id'),
]);
}
/**
* @return array<string, scalar|null>
*/
private function routeParameters(Request $request, EnvironmentReview $review): array
{
$parameters = [
'source_surface' => is_string($request->query('source_surface'))
? (string) $request->query('source_surface')
: 'review_pack',
'review_id' => (int) $review->getKey(),
'tenant_filter_id' => $request->query('tenant_filter_id'),
'interpretation_version' => $review->controlInterpretationVersion(),
];
if ($request->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY)) {
$parameters[CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY] = 1;
}
return array_filter(
$parameters,
static fn (mixed $value): bool => $value !== null && $value !== '',
);
}
/**
* @param array<string, scalar|null> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query(array_filter(
$query,
static fn (mixed $value): bool => $value !== null && $value !== '',
));
}
private function plainText(mixed $value, string $fallback): string
{
if (! is_scalar($value) && $value !== null) {
return $fallback;
}
$text = preg_replace('/\s+/', ' ', trim((string) $value));
if (! is_string($text) || $text === '') {
return $fallback;
}
return str_starts_with($text, 'localization.') ? $fallback : $text;
}
}