TenantAtlas/app/Jobs/ApplyBackupScheduleRetentionJob.php
ahmido c57f680f39 feat: Workspace settings slices v1 (backup, drift, operations) (#120)
Implements Spec 098: workspace-level settings slices for Backup retention, Drift severity mapping, and Operations retention/threshold.

Spec
- specs/098-settings-slices-v1-backup-drift-ops/spec.md

What changed
- Workspace Settings page: grouped Backup/Drift/Operations sections, unset-input UX w/ helper text, per-setting reset actions (confirmed)
- Settings registry: adds/updates validation + normalization (incl. drift severity mapping normalization to lowercase)
- Backup retention: adds workspace default + floor clamp; job clamps effective keep-last up to floor
- Drift findings: optional workspace severity mapping; adds `critical` severity support + badge mapping
- Operations pruning: retention computed per workspace via settings; scheduler unchanged; stuck threshold is storage-only

Safety / Compliance notes
- Filament v5 / Livewire v4: no Livewire v3 usage; relies on existing Filament v5 + Livewire v4 stack
- Provider registration unchanged (Laravel 11+/12 uses bootstrap/providers.php)
- Destructive actions: per-setting reset uses Filament actions with confirmation
- Global search: not affected (no resource changes)
- Assets: no new assets registered; no `filament:assets` changes

Tests
- vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php \
  tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php \
  tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php \
  tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php \
  tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php \
  tests/Unit/Badges/FindingBadgesTest.php

Formatting
- vendor/bin/sail bin pint --dirty

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #120
2026-02-16 03:18:33 +00:00

165 lines
6.1 KiB
PHP

<?php
namespace App\Jobs;
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Services\Intune\AuditLogger;
use App\Services\Settings\SettingsResolver;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class ApplyBackupScheduleRetentionJob implements ShouldQueue
{
use Queueable;
public function __construct(public int $backupScheduleId) {}
public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResolver): void
{
$schedule = BackupSchedule::query()
->with(['tenant.workspace'])
->find($this->backupScheduleId);
if (! $schedule || ! $schedule->tenant) {
return;
}
$operationRun = OperationRun::query()->create([
'workspace_id' => (int) $schedule->tenant->workspace_id,
'tenant_id' => (int) $schedule->tenant_id,
'user_id' => null,
'initiator_name' => 'System',
'type' => 'backup_schedule_retention',
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':backup_schedule_retention:'.$schedule->id.':'.Str::uuid()->toString()),
'context' => [
'backup_schedule_id' => (int) $schedule->id,
],
'started_at' => now(),
]);
$keepLast = $schedule->retention_keep_last;
$retentionFloor = 1;
if ($schedule->tenant->workspace instanceof \App\Models\Workspace) {
$resolvedFloor = $settingsResolver->resolveValue(
workspace: $schedule->tenant->workspace,
domain: 'backup',
key: 'retention_min_floor',
tenant: $schedule->tenant,
);
if (is_numeric($resolvedFloor)) {
$retentionFloor = max(1, (int) $resolvedFloor);
}
}
if ($keepLast === null && $schedule->tenant->workspace instanceof \App\Models\Workspace) {
$resolvedDefault = $settingsResolver->resolveValue(
workspace: $schedule->tenant->workspace,
domain: 'backup',
key: 'retention_keep_last_default',
tenant: $schedule->tenant,
);
if (is_numeric($resolvedDefault)) {
$keepLast = (int) $resolvedDefault;
}
}
if (! is_numeric($keepLast)) {
$keepLast = 30;
}
$keepLast = (int) $keepLast;
if ($keepLast < $retentionFloor) {
$keepLast = $retentionFloor;
}
/** @var Collection<int, int> $keepBackupSetIds */
$keepBackupSetIds = OperationRun::query()
->where('tenant_id', (int) $schedule->tenant_id)
->where('type', 'backup_schedule_run')
->where('status', OperationRunStatus::Completed->value)
->where('context->backup_schedule_id', (int) $schedule->id)
->whereNotNull('context->backup_set_id')
->orderByDesc('completed_at')
->orderByDesc('id')
->limit($keepLast)
->get()
->map(fn (OperationRun $run): ?int => is_numeric(data_get($run->context, 'backup_set_id')) ? (int) data_get($run->context, 'backup_set_id') : null)
->filter(fn (?int $id): bool => is_int($id) && $id > 0)
->values();
/** @var Collection<int, int> $allBackupSetIds */
$allBackupSetIds = OperationRun::query()
->where('tenant_id', (int) $schedule->tenant_id)
->where('type', 'backup_schedule_run')
->where('status', OperationRunStatus::Completed->value)
->where('context->backup_schedule_id', (int) $schedule->id)
->whereNotNull('context->backup_set_id')
->get()
->map(fn (OperationRun $run): ?int => is_numeric(data_get($run->context, 'backup_set_id')) ? (int) data_get($run->context, 'backup_set_id') : null)
->filter(fn (?int $id): bool => is_int($id) && $id > 0)
->unique()
->values();
/** @var Collection<int, int> $deleteBackupSetIds */
$deleteBackupSetIds = $allBackupSetIds
->reject(fn (int $backupSetId): bool => $keepBackupSetIds->contains($backupSetId))
->values();
$deletedCount = 0;
if ($deleteBackupSetIds->isNotEmpty()) {
BackupSet::query()
->where('tenant_id', $schedule->tenant_id)
->whereIn('id', $deleteBackupSetIds->all())
->whereNull('deleted_at')
->chunkById(200, function (Collection $sets) use (&$deletedCount): void {
foreach ($sets as $set) {
$set->delete();
$deletedCount++;
}
});
}
$operationRun->update([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'summary_counts' => [
'total' => (int) $deleteBackupSetIds->count(),
'processed' => (int) $deleteBackupSetIds->count(),
'succeeded' => $deletedCount,
'failed' => max(0, (int) $deleteBackupSetIds->count() - $deletedCount),
'updated' => $deletedCount,
],
'completed_at' => now(),
]);
$auditLogger->log(
tenant: $schedule->tenant,
action: 'backup_schedule.retention_applied',
resourceType: 'backup_schedule',
resourceId: (string) $schedule->id,
status: 'success',
context: [
'metadata' => [
'keep_last' => $keepLast,
'retention_floor' => $retentionFloor,
'deleted_backup_sets' => $deletedCount,
'operation_run_id' => (int) $operationRun->getKey(),
],
],
);
}
}