## 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
200 lines
5.7 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|