## Summary - replace the baseline snapshot detail page with a structured summary-first rendering flow - add a presenter plus renderer registry with RBAC, compliance, and fallback renderers - add grouped policy-type browsing, fidelity and gap badges, and workspace authorization coverage - add Feature 130 spec, plan, contract, research, quickstart, and completed task artifacts ## Testing - focused Pest coverage was added for structured rendering, fallback behavior, degraded states, authorization, presenter logic, renderer resolution, and badge mapping - I did not rerun the full validation suite in this final PR step ## Notes - base branch: `dev` - feature branch: `130-structured-snapshot-rendering` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #158
199 lines
7.2 KiB
PHP
199 lines
7.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Baselines\SnapshotRendering;
|
|
|
|
use App\Models\BaselineSnapshot;
|
|
use App\Models\BaselineSnapshotItem;
|
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
use Throwable;
|
|
|
|
final class BaselineSnapshotPresenter
|
|
{
|
|
public function __construct(
|
|
private readonly SnapshotTypeRendererRegistry $registry,
|
|
) {}
|
|
|
|
public function present(BaselineSnapshot $snapshot): RenderedSnapshot
|
|
{
|
|
$snapshot->loadMissing(['baselineProfile', 'items']);
|
|
|
|
$summary = is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
|
|
$items = $snapshot->items instanceof EloquentCollection
|
|
? $snapshot->items->sortBy([
|
|
['policy_type', 'asc'],
|
|
['id', 'asc'],
|
|
])->values()
|
|
: collect();
|
|
|
|
$groups = $items
|
|
->groupBy(static fn (BaselineSnapshotItem $item): string => (string) $item->policy_type)
|
|
->map(fn (Collection $groupItems, string $policyType): RenderedSnapshotGroup => $this->presentGroup($policyType, $groupItems))
|
|
->sortBy(static fn (RenderedSnapshotGroup $group): string => mb_strtolower($group->label))
|
|
->values()
|
|
->all();
|
|
|
|
$summaryRows = array_map(
|
|
static fn (RenderedSnapshotGroup $group): array => [
|
|
'policyType' => $group->policyType,
|
|
'label' => $group->label,
|
|
'itemCount' => $group->itemCount,
|
|
'fidelity' => $group->fidelity->value,
|
|
'gapCount' => $group->gapSummary->count,
|
|
'capturedAt' => $group->capturedAt,
|
|
'coverageHint' => $group->coverageHint,
|
|
],
|
|
$groups,
|
|
);
|
|
|
|
$overallGapCount = $this->summaryGapCount($summary);
|
|
$overallFidelity = FidelityState::fromSummary($summary, $items->isNotEmpty());
|
|
|
|
return new RenderedSnapshot(
|
|
snapshotId: (int) $snapshot->getKey(),
|
|
baselineProfileName: $snapshot->baselineProfile?->name,
|
|
capturedAt: $snapshot->captured_at?->toIso8601String(),
|
|
snapshotIdentityHash: is_string($snapshot->snapshot_identity_hash) && trim($snapshot->snapshot_identity_hash) !== ''
|
|
? trim($snapshot->snapshot_identity_hash)
|
|
: null,
|
|
stateLabel: $overallGapCount > 0 ? 'Captured with gaps' : 'Complete',
|
|
fidelitySummary: $this->fidelitySummary($summary),
|
|
overallFidelity: $overallFidelity,
|
|
overallGapCount: $overallGapCount,
|
|
summaryRows: $summaryRows,
|
|
groups: $groups,
|
|
technicalDetail: [
|
|
'defaultCollapsed' => true,
|
|
'summaryPayload' => $summary,
|
|
'groupPayloads' => array_map(
|
|
static fn (RenderedSnapshotGroup $group): array => [
|
|
'label' => $group->label,
|
|
'renderingError' => $group->renderingError,
|
|
'payload' => $group->technicalPayload,
|
|
],
|
|
$groups,
|
|
),
|
|
],
|
|
hasItems: $items->isNotEmpty(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, BaselineSnapshotItem> $items
|
|
*/
|
|
private function presentGroup(string $policyType, Collection $items): RenderedSnapshotGroup
|
|
{
|
|
$renderer = $this->registry->rendererFor($policyType);
|
|
$fallbackRenderer = $this->registry->fallbackRenderer();
|
|
$renderingError = null;
|
|
$technicalPayload = $this->technicalPayload($items);
|
|
|
|
try {
|
|
$renderedItems = $items
|
|
->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $renderer->render($item))
|
|
->all();
|
|
} catch (Throwable) {
|
|
$renderedItems = $items
|
|
->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $fallbackRenderer->render($item))
|
|
->all();
|
|
|
|
$renderingError = 'Structured rendering failed for this policy type. Fallback metadata is shown instead.';
|
|
}
|
|
|
|
/** @var array<int, RenderedSnapshotItem> $renderedItems */
|
|
$groupFidelity = FidelityState::aggregate(array_map(
|
|
static fn (RenderedSnapshotItem $item): FidelityState => $item->fidelity,
|
|
$renderedItems,
|
|
));
|
|
|
|
$gapSummary = GapSummary::merge(array_map(
|
|
static fn (RenderedSnapshotItem $item): GapSummary => $item->gapSummary,
|
|
$renderedItems,
|
|
));
|
|
|
|
if ($renderingError !== null) {
|
|
$gapSummary = $gapSummary->withMessage($renderingError);
|
|
}
|
|
|
|
$capturedAt = collect($renderedItems)
|
|
->pluck('observedAt')
|
|
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
|
->sortDesc()
|
|
->first();
|
|
|
|
$coverageHint = $groupFidelity->coverageHint();
|
|
|
|
if ($coverageHint === null && $gapSummary->messages !== []) {
|
|
$coverageHint = $gapSummary->messages[0];
|
|
}
|
|
|
|
return new RenderedSnapshotGroup(
|
|
policyType: $policyType,
|
|
label: $this->typeLabel($policyType),
|
|
itemCount: $items->count(),
|
|
fidelity: $groupFidelity,
|
|
gapSummary: $gapSummary,
|
|
initiallyCollapsed: true,
|
|
items: $renderedItems,
|
|
renderingError: $renderingError,
|
|
coverageHint: $coverageHint,
|
|
capturedAt: is_string($capturedAt) ? $capturedAt : null,
|
|
technicalPayload: $technicalPayload,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, BaselineSnapshotItem> $items
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function technicalPayload(Collection $items): array
|
|
{
|
|
return [
|
|
'items' => $items
|
|
->map(static fn (BaselineSnapshotItem $item): array => [
|
|
'snapshot_item_id' => (int) $item->getKey(),
|
|
'policy_type' => (string) $item->policy_type,
|
|
'meta_jsonb' => is_array($item->meta_jsonb) ? $item->meta_jsonb : [],
|
|
])
|
|
->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $summary
|
|
*/
|
|
private function summaryGapCount(array $summary): int
|
|
{
|
|
$gaps = is_array($summary['gaps'] ?? null) ? $summary['gaps'] : [];
|
|
$count = $gaps['count'] ?? 0;
|
|
|
|
return is_numeric($count) ? (int) $count : 0;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $summary
|
|
*/
|
|
private function fidelitySummary(array $summary): string
|
|
{
|
|
$counts = is_array($summary['fidelity_counts'] ?? null)
|
|
? $summary['fidelity_counts']
|
|
: [];
|
|
|
|
$content = is_numeric($counts['content'] ?? null) ? (int) $counts['content'] : 0;
|
|
$meta = is_numeric($counts['meta'] ?? null) ? (int) $counts['meta'] : 0;
|
|
|
|
return sprintf('Content %d, Meta %d', $content, $meta);
|
|
}
|
|
|
|
private function typeLabel(string $policyType): string
|
|
{
|
|
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
|
|
?? InventoryPolicyTypeMeta::label($policyType)
|
|
?? Str::headline($policyType);
|
|
}
|
|
}
|