TenantAtlas/app/Models/RestoreRun.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

202 lines
5.7 KiB
PHP

<?php
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use App\Support\RestoreRunStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class RestoreRun extends Model
{
use DerivesWorkspaceIdFromTenant;
use HasFactory;
use SoftDeletes;
protected $guarded = [];
protected $casts = [
'is_dry_run' => 'boolean',
'idempotency_key' => 'string',
'requested_items' => 'array',
'preview' => 'array',
'results' => 'array',
'metadata' => 'array',
'group_mapping' => 'array',
'started_at' => 'datetime',
'completed_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function backupSet(): BelongsTo
{
return $this->belongsTo(BackupSet::class)->withTrashed();
}
public function operationRun(): BelongsTo
{
return $this->belongsTo(OperationRun::class);
}
public function scopeDeletable(Builder $query): Builder
{
return $query->whereIn('status', array_map(
static fn (RestoreRunStatus $status): string => $status->value,
[
RestoreRunStatus::Draft,
RestoreRunStatus::Scoped,
RestoreRunStatus::Checked,
RestoreRunStatus::Previewed,
RestoreRunStatus::Completed,
RestoreRunStatus::Partial,
RestoreRunStatus::Failed,
RestoreRunStatus::Cancelled,
RestoreRunStatus::Aborted,
RestoreRunStatus::CompletedWithErrors,
]
));
}
public function isDeletable(): bool
{
$status = RestoreRunStatus::fromString($this->status);
return $status?->isDeletable() ?? false;
}
// Group mapping helpers
public function hasGroupMapping(): bool
{
return ! empty($this->group_mapping);
}
public function getMappedGroupId(string $sourceGroupId): ?string
{
$mapping = $this->group_mapping ?? [];
return $mapping[$sourceGroupId] ?? null;
}
public function isGroupSkipped(string $sourceGroupId): bool
{
$mapping = $this->group_mapping ?? [];
return ($mapping[$sourceGroupId] ?? null) === 'SKIP';
}
public function getUnmappedGroupIds(array $sourceGroupIds): array
{
return array_diff($sourceGroupIds, array_keys($this->group_mapping ?? []));
}
public function addGroupMapping(string $sourceGroupId, string $targetGroupId): void
{
$mapping = $this->group_mapping ?? [];
$mapping[$sourceGroupId] = $targetGroupId;
$this->group_mapping = $mapping;
}
// Assignment restore outcome helpers
public function getAssignmentRestoreOutcomes(): array
{
$results = $this->results ?? [];
if (isset($results['assignment_outcomes']) && is_array($results['assignment_outcomes'])) {
return $results['assignment_outcomes'];
}
if (isset($results['items']) && is_array($results['items'])) {
return collect($results['items'])
->pluck('assignment_outcomes')
->flatten(1)
->filter()
->values()
->all();
}
if (! is_array($results)) {
return [];
}
return collect($results)
->pluck('assignment_outcomes')
->flatten(1)
->filter(static fn (mixed $outcome): bool => is_array($outcome))
->values()
->all();
}
public function getSuccessfulAssignmentsCount(): int
{
return count(array_filter(
$this->getAssignmentRestoreOutcomes(),
static fn (mixed $outcome): bool => is_array($outcome) && ($outcome['status'] ?? null) === 'success'
));
}
public function getFailedAssignmentsCount(): int
{
return count(array_filter(
$this->getAssignmentRestoreOutcomes(),
static fn (mixed $outcome): bool => is_array($outcome) && ($outcome['status'] ?? null) === 'failed'
));
}
public function getSkippedAssignmentsCount(): int
{
return count(array_filter(
$this->getAssignmentRestoreOutcomes(),
static fn (mixed $outcome): bool => is_array($outcome) && ($outcome['status'] ?? null) === 'skipped'
));
}
/**
* @return array<string, mixed>
*/
public function scopeBasis(): array
{
$metadata = is_array($this->metadata) ? $this->metadata : [];
return is_array($metadata['scope_basis'] ?? null) ? $metadata['scope_basis'] : [];
}
/**
* @return array<string, mixed>
*/
public function checkBasis(): array
{
$metadata = is_array($this->metadata) ? $this->metadata : [];
return is_array($metadata['check_basis'] ?? null) ? $metadata['check_basis'] : [];
}
/**
* @return array<string, mixed>
*/
public function previewBasis(): array
{
$metadata = is_array($this->metadata) ? $this->metadata : [];
return is_array($metadata['preview_basis'] ?? null) ? $metadata['preview_basis'] : [];
}
/**
* @return array<string, mixed>
*/
public function executionSafetySnapshot(): array
{
$metadata = is_array($this->metadata) ? $this->metadata : [];
return is_array($metadata['execution_safety_snapshot'] ?? null)
? $metadata['execution_safety_snapshot']
: [];
}
}