*/ public array $restoreRunIds, ?OperationRun $operationRun = null, ) { $this->operationRun = $operationRun; } public function middleware(): array { return [new TrackOperationRun]; } public function handle(OperationRunService $operationRunService): void { $tenant = Tenant::query()->find($this->tenantId); if (! $tenant instanceof Tenant) { throw new \RuntimeException('Tenant not found.'); } $user = User::query()->find($this->userId); if (! $user instanceof User) { throw new \RuntimeException('User not found.'); } $ids = collect($this->restoreRunIds) ->map(static fn ($id): int => (int) $id) ->unique() ->sort() ->values() ->all(); $itemCount = 0; $succeeded = 0; $failed = 0; $skipped = 0; $skipReasons = []; $failures = []; $totalItems = count($ids); $failureThreshold = (int) floor($totalItems / 2); foreach ($ids as $restoreRunId) { $itemCount++; try { /** @var RestoreRun|null $restoreRun */ $restoreRun = RestoreRun::withTrashed() ->where('tenant_id', $tenant->getKey()) ->whereKey($restoreRunId) ->first(); if (! $restoreRun) { $failed++; $failures[] = ['code' => 'restore_run.not_found', 'message' => "Restore run {$restoreRunId} not found."]; if ($failed > $failureThreshold) { if ($this->operationRun) { $operationRunService->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Failed->value, summaryCounts: [ 'total' => $totalItems, 'processed' => $itemCount, 'succeeded' => $succeeded, 'failed' => $failed, 'skipped' => $skipped, 'deleted' => $succeeded, ], failures: array_merge($failures, [ ['code' => 'restore_run.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'], ]), ); } if ($user) { Notification::make() ->title('Bulk Force Delete Aborted') ->body('Circuit breaker triggered: too many failures (>50%).') ->icon('heroicon-o-exclamation-triangle') ->danger() ->actions($this->operationRun ? [ \Filament\Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($this->operationRun, $tenant)), ] : []) ->sendToDatabase($user) ->send(); } return; } continue; } if (! $restoreRun->trashed()) { $skipped++; $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; continue; } $restoreRun->forceDelete(); $succeeded++; } catch (Throwable $e) { $failed++; $failures[] = ['code' => 'restore_run.force_delete.failed', 'message' => $e->getMessage()]; if ($failed > $failureThreshold) { if ($this->operationRun) { $operationRunService->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Failed->value, summaryCounts: [ 'total' => $totalItems, 'processed' => $itemCount, 'succeeded' => $succeeded, 'failed' => $failed, 'skipped' => $skipped, 'deleted' => $succeeded, ], failures: array_merge($failures, [ ['code' => 'restore_run.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'], ]), ); } if ($user) { Notification::make() ->title('Bulk Force Delete Aborted') ->body('Circuit breaker triggered: too many failures (>50%).') ->icon('heroicon-o-exclamation-triangle') ->danger() ->actions($this->operationRun ? [ \Filament\Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($this->operationRun, $tenant)), ] : []) ->sendToDatabase($user) ->send(); } return; } } } $outcome = OperationRunOutcome::Succeeded->value; if ($failed > 0 && $failed < $totalItems) { $outcome = OperationRunOutcome::PartiallySucceeded->value; } if ($failed >= $totalItems && $totalItems > 0) { $outcome = OperationRunOutcome::Failed->value; } if ($this->operationRun) { $operationRunService->updateRun( $this->operationRun, status: OperationRunStatus::Completed->value, outcome: $outcome, summaryCounts: [ 'total' => $totalItems, 'processed' => $totalItems, 'succeeded' => $succeeded, 'failed' => $failed, 'skipped' => $skipped, 'deleted' => $succeeded, ], failures: $failures, ); } $message = "Force deleted {$succeeded} restore runs"; 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 Force Delete Completed') ->body($message) ->icon('heroicon-o-check-circle') ->success() ->actions($this->operationRun ? [ \Filament\Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($this->operationRun, $tenant)), ] : []) ->sendToDatabase($user) ->send(); } }