TenantAtlas/app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php
ahmido 3c445709af feat: add structured baseline snapshot rendering (#158)
## 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
2026-03-10 08:28:06 +00:00

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);
}
}