TenantAtlas/app/Support/RestoreSafety/RestoreScopeFingerprint.php
ahmido a107e7e41b feat: restore safety integrity and queue slide-over (#210)
## Summary
- add the Spec 181 restore-safety layer with scope fingerprinting, preview/check integrity states, execution safety snapshots, result attention, and operator-facing copy across the wizard, restore detail, and canonical operation detail
- add focused unit and feature coverage for restore-safety assessment, result attention, and restore-linked operation detail
- switch the finding exceptions queue `Inspect exception` action to a native Filament slide-over while preserving query-param-backed inline summary behavior

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueTest.php tests/Feature/Filament/RestoreSafetyIntegrityWizardTest.php tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php tests/Feature/Operations/RestoreLinkedOperationDetailTest.php tests/Unit/Support/RestoreSafety`

## Notes
- Spec 181 checklist is complete (`specs/181-restore-safety-integrity/checklists/requirements.md`)
- the branch still has unchecked follow-up tasks in `specs/181-restore-safety-integrity/tasks.md`: `T012`, `T018`, `T019`, `T023`, `T025`, `T029`, `T032`, `T033`, `T041`, `T042`, `T043`, `T044`
- Filament v5 / Livewire v4 compliance is preserved, no panel provider registration changes were made, no global-search behavior was added, destructive actions remain confirmation-gated, and no new Filament assets were introduced

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #210
2026-04-06 23:37:14 +00:00

200 lines
5.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\RestoreSafety;
use InvalidArgumentException;
use JsonException;
final readonly class RestoreScopeFingerprint
{
public function __construct(
public ?int $backupSetId,
public string $scopeMode,
/**
* @var list<int>
*/
public array $selectedItemIds,
/**
* @var array<string, string>
*/
public array $groupMapping,
public string $groupMappingFingerprint,
public string $fingerprint,
) {
if (! in_array($this->scopeMode, ['all', 'selected'], true)) {
throw new InvalidArgumentException('Restore scope mode must be all or selected.');
}
}
public static function fromInputs(
mixed $backupSetId,
mixed $scopeMode,
mixed $selectedItemIds,
mixed $groupMapping,
): self {
$normalizedBackupSetId = is_numeric($backupSetId) ? max(1, (int) $backupSetId) : null;
$normalizedScopeMode = $scopeMode === 'selected' ? 'selected' : 'all';
$normalizedSelectedItemIds = self::normalizeItemIds(
$normalizedScopeMode === 'selected' ? $selectedItemIds : []
);
$normalizedGroupMapping = self::normalizeGroupMapping($groupMapping);
$groupMappingFingerprint = self::hashPayload($normalizedGroupMapping);
return new self(
backupSetId: $normalizedBackupSetId,
scopeMode: $normalizedScopeMode,
selectedItemIds: $normalizedSelectedItemIds,
groupMapping: $normalizedGroupMapping,
groupMappingFingerprint: $groupMappingFingerprint,
fingerprint: self::hashPayload([
'backup_set_id' => $normalizedBackupSetId,
'scope_mode' => $normalizedScopeMode,
'selected_item_ids' => $normalizedSelectedItemIds,
'group_mapping_fingerprint' => $groupMappingFingerprint,
]),
);
}
public static function fromArray(mixed $payload): ?self
{
if (! is_array($payload)) {
return null;
}
if (
! array_key_exists('backup_set_id', $payload)
&& ! array_key_exists('scope_mode', $payload)
&& ! array_key_exists('fingerprint', $payload)
) {
return null;
}
return self::fromInputs(
$payload['backup_set_id'] ?? null,
$payload['scope_mode'] ?? null,
$payload['selected_item_ids'] ?? [],
$payload['group_mapping'] ?? [],
);
}
public function matches(?string $fingerprint): bool
{
return is_string($fingerprint)
&& $fingerprint !== ''
&& hash_equals($this->fingerprint, $fingerprint);
}
/**
* @return array{
* backup_set_id: ?int,
* scope_mode: string,
* selected_item_ids: list<int>,
* group_mapping: array<string, string>,
* group_mapping_fingerprint: string,
* fingerprint: string
* }
*/
public function toArray(): array
{
return [
'backup_set_id' => $this->backupSetId,
'scope_mode' => $this->scopeMode,
'selected_item_ids' => $this->selectedItemIds,
'group_mapping' => $this->groupMapping,
'group_mapping_fingerprint' => $this->groupMappingFingerprint,
'fingerprint' => $this->fingerprint,
];
}
/**
* @return list<int>
*/
private static function normalizeItemIds(mixed $selectedItemIds): array
{
if (! is_array($selectedItemIds)) {
return [];
}
$normalized = [];
foreach ($selectedItemIds as $itemId) {
if (is_int($itemId) && $itemId > 0) {
$normalized[] = $itemId;
continue;
}
if (is_string($itemId) && ctype_digit($itemId) && (int) $itemId > 0) {
$normalized[] = (int) $itemId;
}
}
$normalized = array_values(array_unique($normalized));
sort($normalized);
return $normalized;
}
/**
* @return array<string, string>
*/
private static function normalizeGroupMapping(mixed $groupMapping): array
{
if ($groupMapping instanceof \Illuminate\Contracts\Support\Arrayable) {
$groupMapping = $groupMapping->toArray();
}
if ($groupMapping instanceof \stdClass) {
$groupMapping = (array) $groupMapping;
}
if (! is_array($groupMapping)) {
return [];
}
$normalized = [];
foreach ($groupMapping as $sourceGroupId => $targetGroupId) {
if (! is_string($sourceGroupId) || trim($sourceGroupId) === '') {
continue;
}
if ($targetGroupId instanceof \BackedEnum) {
$targetGroupId = $targetGroupId->value;
}
if (! is_string($targetGroupId)) {
continue;
}
$targetGroupId = trim($targetGroupId);
if ($targetGroupId === '') {
continue;
}
$normalized[trim($sourceGroupId)] = strtoupper($targetGroupId) === 'SKIP'
? 'SKIP'
: $targetGroupId;
}
ksort($normalized);
return $normalized;
}
/**
* @param array<mixed> $payload
*/
private static function hashPayload(array $payload): string
{
try {
return hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
} catch (JsonException $exception) {
throw new InvalidArgumentException('Restore scope payload could not be fingerprinted.', previous: $exception);
}
}
}