diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index 511cda3..83900d4 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -4,20 +4,27 @@ use App\Filament\Resources\BackupSetResource\Pages; use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager; +use App\Jobs\BulkBackupSetDeleteJob; use App\Models\BackupSet; use App\Models\Tenant; +use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Intune\BackupService; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; +use Filament\Actions\BulkAction; +use Filament\Actions\BulkActionGroup; use Filament\Forms; use Filament\Infolists; use Filament\Notifications\Notification; use Filament\Resources\Resource; use Filament\Schemas\Schema; use Filament\Tables; +use Filament\Tables\Contracts\HasTable; +use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Collection; use UnitEnum; class BackupSetResource extends Resource @@ -135,7 +142,65 @@ public static function table(Table $table): Table }), ])->icon('heroicon-o-ellipsis-vertical'), ]) - ->bulkActions([]); + ->bulkActions([ + BulkActionGroup::make([ + BulkAction::make('bulk_delete') + ->label('Archive Backup Sets') + ->icon('heroicon-o-archive-box-x-mark') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return $isOnlyTrashed; + }) + ->modalDescription('This archives backup sets (soft delete). Backup sets referenced by restore runs will be skipped.') + ->form(function (Collection $records) { + if ($records->count() >= 10) { + return [ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]; + } + + return []; + }) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'backup_set', 'delete', $ids, $count); + + if ($count >= 10) { + Notification::make() + ->title('Bulk archive started') + ->body("Archiving {$count} backup sets in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkBackupSetDeleteJob::dispatch($run->id); + } else { + BulkBackupSetDeleteJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + ]), + ]); } public static function infolist(Schema $schema): Schema diff --git a/app/Jobs/BulkBackupSetDeleteJob.php b/app/Jobs/BulkBackupSetDeleteJob.php new file mode 100644 index 0000000..370bb77 --- /dev/null +++ b/app/Jobs/BulkBackupSetDeleteJob.php @@ -0,0 +1,158 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + $itemCount = 0; + $succeeded = 0; + $failed = 0; + $skipped = 0; + $skipReasons = []; + + $chunkSize = 10; + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $backupSetId) { + $itemCount++; + + try { + /** @var BackupSet|null $backupSet */ + $backupSet = BackupSet::withTrashed() + ->where('tenant_id', $run->tenant_id) + ->whereKey($backupSetId) + ->first(); + + if (! $backupSet) { + $service->recordFailure($run, (string) $backupSetId, 'Backup set not found'); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Archive Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if ($backupSet->trashed()) { + $service->recordSkippedWithReason($run, (string) $backupSet->id, 'Already archived'); + $skipped++; + $skipReasons['Already archived'] = ($skipReasons['Already archived'] ?? 0) + 1; + + continue; + } + + if ($backupSet->restoreRuns()->withTrashed()->exists()) { + $service->recordSkippedWithReason($run, (string) $backupSet->id, 'Referenced by restore runs'); + $skipped++; + $skipReasons['Referenced by restore runs'] = ($skipReasons['Referenced by restore runs'] ?? 0) + 1; + + continue; + } + + $backupSet->delete(); + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $backupSetId, $e->getMessage()); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Archive Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if (! $run->user) { + return; + } + + $message = "Archived {$succeeded} backup sets"; + if ($skipped > 0) { + $message .= " ({$skipped} skipped)"; + } + if ($failed > 0) { + $message .= " ({$failed} failed)"; + } + + if (! empty($skipReasons)) { + $summary = collect($skipReasons) + ->sortDesc() + ->map(fn (int $count, string $reason) => "{$reason} ({$count})") + ->take(3) + ->implode(', '); + + if ($summary !== '') { + $message .= " Skip reasons: {$summary}."; + } + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Archive Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } +} diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md index e58d7ad..c044d94 100644 --- a/specs/005-bulk-operations/tasks.md +++ b/specs/005-bulk-operations/tasks.md @@ -253,17 +253,17 @@ ## Phase 9: Additional Resource - Bulk Delete Backup Sets (Priority: P2) ### Tests for Additional Resource -- [ ] T078 [P] Write unit test for BulkBackupSetDeleteJob in tests/Unit/BulkBackupSetDeleteJobTest.php -- [ ] T079 [P] Write feature test for bulk delete backup sets in tests/Feature/BulkDeleteBackupSetsTest.php +- [x] T078 [P] Write unit test for BulkBackupSetDeleteJob in tests/Unit/BulkBackupSetDeleteJobTest.php +- [x] T079 [P] Write feature test for bulk delete backup sets in tests/Feature/BulkDeleteBackupSetsTest.php ### Implementation for Additional Resource -- [ ] T080 [P] Create BulkBackupSetDeleteJob in app/Jobs/BulkBackupSetDeleteJob.php -- [ ] T081 Add bulk delete action to BackupSetResource in app/Filament/Resources/BackupSetResource.php -- [ ] T082 Verify cascade-delete logic for backup items (should be automatic via foreign key) -- [ ] T083 Add type-to-confirm for ≥10 sets +- [x] T080 [P] Create BulkBackupSetDeleteJob in app/Jobs/BulkBackupSetDeleteJob.php +- [x] T081 Add bulk delete action to BackupSetResource in app/Filament/Resources/BackupSetResource.php +- [x] T082 Verify cascade-delete logic for backup items (should be automatic via foreign key) +- [x] T083 Add type-to-confirm for ≥10 sets - [ ] T084 Test delete with 15 backup sets (manual QA) -- [ ] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php` +- [x] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php` ### Additional: Bulk Archive Backup Sets (FR-005.23) diff --git a/tests/Feature/BulkDeleteBackupSetsTest.php b/tests/Feature/BulkDeleteBackupSetsTest.php new file mode 100644 index 0000000..eba84c5 --- /dev/null +++ b/tests/Feature/BulkDeleteBackupSetsTest.php @@ -0,0 +1,81 @@ +create(); + $user = User::factory()->create(); + + $sets = collect(range(1, 3))->map(function (int $i) use ($tenant) { + return BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup '.$i, + 'status' => 'completed', + 'item_count' => 1, + ]); + }); + + $sets->each(function (BackupSet $set) use ($tenant) { + BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-'.$set->id, + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + 'payload' => ['id' => 'policy-'.$set->id], + 'metadata' => null, + ]); + }); + + Livewire::actingAs($user) + ->test(BackupSetResource\Pages\ListBackupSets::class) + ->callTableBulkAction('bulk_delete', $sets) + ->assertHasNoTableBulkActionErrors(); + + $sets->each(fn (BackupSet $set) => expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue()); + + $bulkRun = BulkOperationRun::query() + ->where('resource', 'backup_set') + ->where('action', 'delete') + ->latest('id') + ->first(); + + expect($bulkRun)->not->toBeNull(); + expect($bulkRun->status)->toBe('completed'); +}); + +test('backup sets table bulk archive requires type-to-confirm for 10+ sets', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $sets = collect(range(1, 10))->map(function (int $i) use ($tenant) { + return BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup '.$i, + 'status' => 'completed', + 'item_count' => 0, + ]); + }); + + Livewire::actingAs($user) + ->test(BackupSetResource\Pages\ListBackupSets::class) + ->callTableBulkAction('bulk_delete', $sets) + ->assertHasTableBulkActionErrors(); + + Livewire::actingAs($user) + ->test(BackupSetResource\Pages\ListBackupSets::class) + ->callTableBulkAction('bulk_delete', $sets, data: [ + 'confirmation' => 'DELETE', + ]) + ->assertHasNoTableBulkActionErrors(); +}); diff --git a/tests/Unit/BulkBackupSetDeleteJobTest.php b/tests/Unit/BulkBackupSetDeleteJobTest.php new file mode 100644 index 0000000..fa44a5d --- /dev/null +++ b/tests/Unit/BulkBackupSetDeleteJobTest.php @@ -0,0 +1,91 @@ +create(); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 2, + ]); + + $items = collect(range(1, 2))->map(function (int $i) use ($tenant, $set) { + return BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-'.$i, + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + 'payload' => ['id' => 'policy-'.$i], + 'metadata' => null, + ]); + }); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'backup_set', 'delete', [$set->id], 1); + + (new BulkBackupSetDeleteJob($run->id))->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->processed_items)->toBe(1) + ->and($run->succeeded)->toBe(1) + ->and($run->failed)->toBe(0) + ->and($run->skipped)->toBe(0); + + expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue(); + + $items->each(function (BackupItem $item) { + expect(BackupItem::withTrashed()->find($item->id)?->trashed())->toBeTrue(); + }); +}); + +test('bulk backup set delete job skips sets referenced by restore runs', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'backup_set', 'delete', [$set->id], 1); + + (new BulkBackupSetDeleteJob($run->id))->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->processed_items)->toBe(1) + ->and($run->succeeded)->toBe(0) + ->and($run->failed)->toBe(0) + ->and($run->skipped)->toBe(1); + + expect(collect($run->failures)->pluck('reason')->join(' '))->toContain('restore runs'); + expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeFalse(); +});