From a97beefda323e6c8a6ee9fc20374372d2fc081de Mon Sep 17 00:00:00 2001 From: ahmido Date: Mon, 19 Jan 2026 23:27:52 +0000 Subject: [PATCH] 056-remove-legacy-bulkops (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kurzbeschreibung Versteckt die Rerun-Row-Action für archivierte (soft-deleted) RestoreRuns und verhindert damit fehlerhafte Neu-Starts aus dem Archiv; ergänzt einen Regressionstest. Änderungen Code: RestoreRunResource.php — Sichtbarkeit der rerun-Action geprüft auf ! $record->trashed() und defensive Abbruchprüfung im Action-Handler. Tests: RestoreRunRerunTest.php — neuer Test rerun action is hidden for archived restore runs. Warum Archivierte RestoreRuns durften nicht neu gestartet werden; UI zeigte trotzdem die Option. Das führte zu verwirrendem Verhalten und möglichen Fehlern beim Enqueueing. Verifikation / QA Unit/Feature: ./vendor/bin/sail artisan test tests/Feature/RestoreRunRerunTest.php Stil/format: ./vendor/bin/pint --dirty Manuell (UI): Als Tenant-Admin Filament → Restore Runs öffnen. Filter Archived aktivieren (oder Trashed filter auswählen). Sicherstellen, dass für archivierte Einträge die Rerun-Action nicht sichtbar ist. Auf einem aktiven (nicht-archivierten) Run prüfen, dass Rerun sichtbar bleibt und wie erwartet eine neue RestoreRun erzeugt. Wichtige Hinweise Kein DB-Migration required. Diese PR enthält nur den UI-/Filament-Fix; die zuvor gemachten operative Fixes für Queue/adapter-Reconciliation bleiben ebenfalls auf dem Branch (z. B. frühere commits während der Debugging-Session). T055 (Schema squash) wurde bewusst zurückgestellt und ist nicht Teil dieses PRs. Merge-Checklist Tests lokal laufen (RestoreRunRerunTest grünt) Pint läuft ohne ungepatchte Fehler Branch gepusht: 056-remove-legacy-bulkops (PR-URL: https://git.cloudarix.de/ahmido/TenantAtlas/compare/dev...056-remove-legacy-bulkops) Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/65 --- .github/agents/copilot-instructions.md | 3 +- .../Commands/OpsReconcileAdapterRuns.php | 116 +++++ .../TenantpilotPurgeNonPersistentData.php | 6 +- ...otReconcileBackupScheduleOperationRuns.php | 52 ++- app/Filament/Pages/DriftLanding.php | 143 +++--- app/Filament/Pages/InventoryLanding.php | 16 +- .../Resources/BackupScheduleResource.php | 69 +-- app/Filament/Resources/BackupSetResource.php | 173 ++++++-- .../Resources/BulkOperationRunResource.php | 388 ----------------- .../Pages/ListBulkOperationRuns.php | 11 - .../Pages/ViewBulkOperationRun.php | 11 - .../Resources/OperationRunResource.php | 60 ++- app/Filament/Resources/PolicyResource.php | 238 ++++++++-- .../PolicyResource/Pages/ViewPolicy.php | 80 ++-- .../Resources/PolicyVersionResource.php | 187 ++++++-- app/Filament/Resources/RestoreRunResource.php | 223 +++++++--- app/Filament/Resources/TenantResource.php | 73 ++-- app/Jobs/AddPoliciesToBackupSetJob.php | 409 ++++++++---------- app/Jobs/BulkBackupSetDeleteJob.php | 172 +++----- app/Jobs/BulkBackupSetForceDeleteJob.php | 182 +++----- app/Jobs/BulkBackupSetRestoreJob.php | 207 +++++---- app/Jobs/BulkPolicyDeleteJob.php | 207 +++------ app/Jobs/BulkPolicyExportJob.php | 233 +++++++--- app/Jobs/BulkPolicySyncJob.php | 139 +----- app/Jobs/BulkPolicyUnignoreJob.php | 134 ++++-- app/Jobs/BulkPolicyVersionForceDeleteJob.php | 184 +++----- app/Jobs/BulkPolicyVersionPruneJob.php | 214 +++------ app/Jobs/BulkPolicyVersionRestoreJob.php | 184 +++----- app/Jobs/BulkRestoreRunDeleteJob.php | 213 +++------ app/Jobs/BulkRestoreRunForceDeleteJob.php | 155 +++++-- app/Jobs/BulkRestoreRunRestoreJob.php | 152 +++++-- app/Jobs/BulkTenantSyncJob.php | 189 +++----- app/Jobs/CapturePolicySnapshotJob.php | 107 ++--- app/Jobs/ExecuteRestoreRunJob.php | 6 +- app/Jobs/GenerateDriftFindingsJob.php | 199 +++------ .../Operations/BackupSetDeleteWorkerJob.php | 120 +++++ .../BackupSetForceDeleteWorkerJob.php | 137 ++++++ .../Operations/BackupSetRestoreWorkerJob.php | 121 ++++++ .../BulkOperationOrchestratorJob.php | 101 +++++ .../Operations/BulkOperationWorkerJob.php | 66 +++ .../CapturePolicySnapshotWorkerJob.php | 124 ++++++ .../Operations/PolicyBulkDeleteWorkerJob.php | 120 +++++ .../PolicyVersionForceDeleteWorkerJob.php | 120 +++++ .../PolicyVersionPruneWorkerJob.php | 138 ++++++ .../PolicyVersionRestoreWorkerJob.php | 120 +++++ .../Operations/RestoreRunDeleteWorkerJob.php | 131 ++++++ app/Jobs/Operations/TenantSyncWorkerJob.php | 136 ++++++ app/Jobs/ReconcileAdapterRunsJob.php | 51 +++ app/Jobs/RemovePoliciesFromBackupSetJob.php | 30 +- app/Jobs/RunBackupScheduleJob.php | 126 ++---- app/Jobs/RunInventorySyncJob.php | 138 +++--- app/Jobs/SyncPoliciesJob.php | 65 +-- .../SyncRestoreRunToOperationRun.php | 15 +- app/Livewire/BackupSetPolicyPickerTable.php | 159 ++----- app/Models/BulkOperationRun.php | 104 ----- app/Models/RestoreRun.php | 8 +- .../RunStatusChangedNotification.php | 4 +- app/Policies/BulkOperationRunPolicy.php | 39 -- app/Policies/OperationRunPolicy.php | 9 +- app/Providers/AppServiceProvider.php | 3 - app/Services/AdapterRunReconciler.php | 273 ++++++++++++ app/Services/BulkOperationService.php | 269 ------------ app/Services/Graph/MicrosoftGraphClient.php | 59 ++- app/Services/OperationRunService.php | 319 +++++++++++++- .../Operations/BulkIdempotencyFingerprint.php | 43 ++ .../Operations/BulkSelectionIdentity.php | 87 ++++ .../TargetScopeConcurrencyLimiter.php | 72 +++ app/Support/OperationCatalog.php | 8 + app/Support/OpsUx/BulkRunContext.php | 67 +++ app/Support/OpsUx/RunFailureSanitizer.php | 103 +++++ ...mpotency.php => RestoreRunIdempotency.php} | 13 +- config/tenantpilot.php | 4 + .../factories/BulkOperationRunFactory.php | 34 -- ..._000001_drop_bulk_operation_runs_table.php | 45 ++ database/seeders/BulkOperationsTestSeeder.php | 33 -- routes/console.php | 6 + specs/005-bulk-operations/quickstart.md | 7 +- .../checklists/requirements.md | 35 ++ .../operation-run-context.bulk.schema.json | 58 +++ .../contracts/operations.bulk.openapi.yaml | 66 +++ specs/056-remove-legacy-bulkops/data-model.md | 87 ++++ specs/056-remove-legacy-bulkops/discovery.md | 76 ++++ specs/056-remove-legacy-bulkops/plan.md | 163 +++++++ specs/056-remove-legacy-bulkops/quickstart.md | 52 +++ specs/056-remove-legacy-bulkops/research.md | 89 ++++ specs/056-remove-legacy-bulkops/spec.md | 190 ++++++++ specs/056-remove-legacy-bulkops/tasks.md | 259 +++++++++++ .../RunBackupScheduleJobTest.php | 21 +- .../RunNowRetryActionsTest.php | 33 -- .../AddPoliciesToBackupSetJobTest.php | 62 +-- .../RemovePoliciesJobNotificationTest.php | 3 +- tests/Feature/BulkDeleteBackupSetsTest.php | 13 +- tests/Feature/BulkDeleteMixedStatusTest.php | 14 +- tests/Feature/BulkDeletePoliciesAsyncTest.php | 30 +- tests/Feature/BulkDeletePoliciesTest.php | 49 ++- tests/Feature/BulkDeleteRestoreRunsTest.php | 13 +- tests/Feature/BulkExportFailuresTest.php | 46 +- tests/Feature/BulkExportToBackupTest.php | 33 +- .../Feature/BulkForceDeleteBackupSetsTest.php | 13 +- .../BulkForceDeletePolicyVersionsTest.php | 15 +- .../BulkForceDeleteRestoreRunsTest.php | 14 +- .../Feature/BulkProgressNotificationTest.php | 97 ++--- tests/Feature/BulkPruneSkipReasonsTest.php | 14 +- tests/Feature/BulkRestoreBackupSetsTest.php | 13 +- .../Feature/BulkRestorePolicyVersionsTest.php | 15 +- tests/Feature/BulkRestoreRestoreRunsTest.php | 17 +- tests/Feature/BulkSyncPoliciesTest.php | 62 ++- tests/Feature/BulkUnignorePoliciesTest.php | 35 +- .../PurgeNonPersistentDataCommandTest.php | 6 +- ...BackupScheduleOperationRunsCommandTest.php | 12 +- .../DriftCompletedRunWithZeroFindingsTest.php | 42 +- .../Drift/DriftGenerationDispatchTest.php | 50 +-- ...nerateDriftFindingsJobNotificationTest.php | 82 ++-- tests/Feature/ExecuteRestoreRunJobTest.php | 5 +- .../BackupSetPolicyPickerTableTest.php | 66 ++- .../PolicyCaptureSnapshotOptionsTest.php | 47 ++ .../TenantPortfolioContextSwitchTest.php | 7 +- .../Guards/NoAdHocRetryInBulkWorkersTest.php | 32 ++ .../Guards/NoLegacyBulkOperationsTest.php | 101 +++++ .../Inventory/InventorySyncButtonTest.php | 13 +- .../Inventory/RunInventorySyncJobTest.php | 71 +-- tests/Feature/MonitoringOperationsTest.php | 20 +- .../OpsUx/AdapterRunReconcilerTest.php | 184 ++++++++ .../OpsUx/BackupSetDeleteBulkJobTest.php | 142 ++++++ .../OpsUx/BulkEnqueueIdempotencyTest.php | 74 ++++ .../Feature/OpsUx/BulkTenantIsolationTest.php | 31 ++ .../Feature/OpsUx/FailureSanitizationTest.php | 62 +++ .../OpsUx/NotificationViewRunLinkTest.php | 29 ++ ...OperationRunSummaryCountsIncrementTest.php | 45 ++ .../PolicyVersionForceDeleteBulkJobTest.php | 150 +++++++ .../OpsUx/PolicyVersionPruneBulkJobTest.php | 155 +++++++ .../RestoreExecuteOperationRunSyncTest.php | 57 +++ .../RestoreExecutionOperationRunSyncTest.php | 6 - .../OpsUx/RestoreRunDeleteBulkJobTest.php | 158 +++++++ .../OpsUx/SummaryCountsWhitelistTest.php | 69 +++ .../TargetScopeConcurrencyLimiterTest.php | 25 ++ tests/Feature/OpsUx/TenantSyncBulkJobTest.php | 165 +++++++ .../PolicyCaptureSnapshotIdempotencyTest.php | 9 +- .../PolicyCaptureSnapshotQueuedTest.php | 9 +- tests/Feature/RestoreAdapterTest.php | 32 +- tests/Feature/RestoreAuditLoggingTest.php | 3 +- tests/Feature/RestoreRunRerunTest.php | 32 ++ .../RunAuthorizationTenantIsolationTest.php | 59 +-- tests/Feature/RunStartAuthorizationTest.php | 4 +- tests/Unit/BulkBackupSetDeleteJobTest.php | 61 ++- .../Unit/BulkBackupSetForceDeleteJobTest.php | 85 ++-- tests/Unit/BulkBackupSetRestoreJobTest.php | 81 ++-- tests/Unit/BulkOperationAbortMethodTest.php | 35 +- tests/Unit/BulkOperationRunProgressTest.php | 158 +++---- .../Unit/BulkOperationRunStatusBucketTest.php | 73 +--- tests/Unit/BulkPolicyDeleteJobTest.php | 80 +++- tests/Unit/BulkPolicyExportJobTest.php | 67 ++- .../BulkPolicyVersionForceDeleteJobTest.php | 90 +++- tests/Unit/BulkPolicyVersionPruneJobTest.php | 149 +++++-- .../Unit/BulkPolicyVersionRestoreJobTest.php | 70 +-- tests/Unit/BulkRestoreRunDeleteJobTest.php | 145 ++++++- tests/Unit/BulkRestoreRunRestoreJobTest.php | 70 +-- tests/Unit/CircuitBreakerTest.php | 68 ++- .../MicrosoftGraphClientRetryPolicyTest.php | 28 ++ .../Operations/BulkSelectionIdentityTest.php | 44 ++ tests/Unit/RunIdempotencyTest.php | 57 +-- 161 files changed, 9244 insertions(+), 4620 deletions(-) create mode 100644 app/Console/Commands/OpsReconcileAdapterRuns.php delete mode 100644 app/Filament/Resources/BulkOperationRunResource.php delete mode 100644 app/Filament/Resources/BulkOperationRunResource/Pages/ListBulkOperationRuns.php delete mode 100644 app/Filament/Resources/BulkOperationRunResource/Pages/ViewBulkOperationRun.php create mode 100644 app/Jobs/Operations/BackupSetDeleteWorkerJob.php create mode 100644 app/Jobs/Operations/BackupSetForceDeleteWorkerJob.php create mode 100644 app/Jobs/Operations/BackupSetRestoreWorkerJob.php create mode 100644 app/Jobs/Operations/BulkOperationOrchestratorJob.php create mode 100644 app/Jobs/Operations/BulkOperationWorkerJob.php create mode 100644 app/Jobs/Operations/CapturePolicySnapshotWorkerJob.php create mode 100644 app/Jobs/Operations/PolicyBulkDeleteWorkerJob.php create mode 100644 app/Jobs/Operations/PolicyVersionForceDeleteWorkerJob.php create mode 100644 app/Jobs/Operations/PolicyVersionPruneWorkerJob.php create mode 100644 app/Jobs/Operations/PolicyVersionRestoreWorkerJob.php create mode 100644 app/Jobs/Operations/RestoreRunDeleteWorkerJob.php create mode 100644 app/Jobs/Operations/TenantSyncWorkerJob.php create mode 100644 app/Jobs/ReconcileAdapterRunsJob.php delete mode 100644 app/Models/BulkOperationRun.php delete mode 100644 app/Policies/BulkOperationRunPolicy.php create mode 100644 app/Services/AdapterRunReconciler.php delete mode 100644 app/Services/BulkOperationService.php create mode 100644 app/Services/Operations/BulkIdempotencyFingerprint.php create mode 100644 app/Services/Operations/BulkSelectionIdentity.php create mode 100644 app/Services/Operations/TargetScopeConcurrencyLimiter.php create mode 100644 app/Support/OpsUx/BulkRunContext.php create mode 100644 app/Support/OpsUx/RunFailureSanitizer.php rename app/Support/{RunIdempotency.php => RestoreRunIdempotency.php} (84%) delete mode 100644 database/factories/BulkOperationRunFactory.php create mode 100644 database/migrations/2026_01_18_000001_drop_bulk_operation_runs_table.php delete mode 100644 database/seeders/BulkOperationsTestSeeder.php create mode 100644 specs/056-remove-legacy-bulkops/checklists/requirements.md create mode 100644 specs/056-remove-legacy-bulkops/contracts/operation-run-context.bulk.schema.json create mode 100644 specs/056-remove-legacy-bulkops/contracts/operations.bulk.openapi.yaml create mode 100644 specs/056-remove-legacy-bulkops/data-model.md create mode 100644 specs/056-remove-legacy-bulkops/discovery.md create mode 100644 specs/056-remove-legacy-bulkops/plan.md create mode 100644 specs/056-remove-legacy-bulkops/quickstart.md create mode 100644 specs/056-remove-legacy-bulkops/research.md create mode 100644 specs/056-remove-legacy-bulkops/spec.md create mode 100644 specs/056-remove-legacy-bulkops/tasks.md create mode 100644 tests/Feature/Guards/NoAdHocRetryInBulkWorkersTest.php create mode 100644 tests/Feature/Guards/NoLegacyBulkOperationsTest.php create mode 100644 tests/Feature/OpsUx/AdapterRunReconcilerTest.php create mode 100644 tests/Feature/OpsUx/BackupSetDeleteBulkJobTest.php create mode 100644 tests/Feature/OpsUx/BulkEnqueueIdempotencyTest.php create mode 100644 tests/Feature/OpsUx/BulkTenantIsolationTest.php create mode 100644 tests/Feature/OpsUx/FailureSanitizationTest.php create mode 100644 tests/Feature/OpsUx/OperationRunSummaryCountsIncrementTest.php create mode 100644 tests/Feature/OpsUx/PolicyVersionForceDeleteBulkJobTest.php create mode 100644 tests/Feature/OpsUx/PolicyVersionPruneBulkJobTest.php create mode 100644 tests/Feature/OpsUx/RestoreExecuteOperationRunSyncTest.php create mode 100644 tests/Feature/OpsUx/RestoreRunDeleteBulkJobTest.php create mode 100644 tests/Feature/OpsUx/TargetScopeConcurrencyLimiterTest.php create mode 100644 tests/Feature/OpsUx/TenantSyncBulkJobTest.php create mode 100644 tests/Unit/MicrosoftGraphClientRetryPolicyTest.php create mode 100644 tests/Unit/Operations/BulkSelectionIdentityTest.php diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index e53c727..310d6a9 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -10,6 +10,7 @@ ## Active Technologies - PostgreSQL (JSONB) (feat/042-inventory-dependencies-graph) - PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3 (feat/047-inventory-foundations-nodes) - PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) (feat/047-inventory-foundations-nodes) +- PostgreSQL (JSONB in `operation_runs.context`, `operation_runs.summary_counts`) (056-remove-legacy-bulkops) - PHP 8.4.15 (feat/005-bulk-operations) @@ -29,9 +30,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 056-remove-legacy-bulkops: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3 - feat/047-inventory-foundations-nodes: Added PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3 - feat/042-inventory-dependencies-graph: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3 -- feat/032-backup-scheduling-mvp: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 diff --git a/app/Console/Commands/OpsReconcileAdapterRuns.php b/app/Console/Commands/OpsReconcileAdapterRuns.php new file mode 100644 index 0000000..39e73fa --- /dev/null +++ b/app/Console/Commands/OpsReconcileAdapterRuns.php @@ -0,0 +1,116 @@ +option('type'); + $type = is_string($type) && trim($type) !== '' ? trim($type) : null; + + $tenantId = $this->option('tenant'); + $tenantId = is_numeric($tenantId) ? (int) $tenantId : null; + + $olderThanMinutes = $this->option('older-than'); + $olderThanMinutes = is_numeric($olderThanMinutes) ? (int) $olderThanMinutes : 60; + $olderThanMinutes = max(1, $olderThanMinutes); + + $limit = $this->option('limit'); + $limit = is_numeric($limit) ? (int) $limit : 50; + $limit = max(1, $limit); + + $dryRun = $this->option('dry-run'); + $dryRun = filter_var($dryRun, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); + $dryRun = $dryRun ?? true; + + $result = $reconciler->reconcile([ + 'type' => $type, + 'tenant_id' => $tenantId, + 'older_than_minutes' => $olderThanMinutes, + 'limit' => $limit, + 'dry_run' => $dryRun, + ]); + + $changes = $result['changes'] ?? []; + + usort($changes, static fn (array $a, array $b): int => ((int) ($a['operation_run_id'] ?? 0)) <=> ((int) ($b['operation_run_id'] ?? 0))); + + $this->info('Adapter run reconciliation'); + $this->line('dry_run: '.($dryRun ? 'true' : 'false')); + $this->line('type: '.($type ?? '(all supported)')); + $this->line('tenant: '.($tenantId ? (string) $tenantId : '(all)')); + $this->line('older_than_minutes: '.$olderThanMinutes); + $this->line('limit: '.$limit); + $this->newLine(); + + $this->line('candidates: '.(int) ($result['candidates'] ?? 0)); + $this->line('reconciled: '.(int) ($result['reconciled'] ?? 0)); + $this->line('skipped: '.(int) ($result['skipped'] ?? 0)); + $this->newLine(); + + if ($changes === []) { + $this->info('No changes.'); + + return self::SUCCESS; + } + + $rows = []; + + foreach ($changes as $change) { + $before = is_array($change['before'] ?? null) ? $change['before'] : []; + $after = is_array($change['after'] ?? null) ? $change['after'] : []; + + $rows[] = [ + 'applied' => ($change['applied'] ?? false) ? 'yes' : 'no', + 'operation_run_id' => (int) ($change['operation_run_id'] ?? 0), + 'type' => (string) ($change['type'] ?? ''), + 'source_id' => (int) ($change['restore_run_id'] ?? 0), + 'before' => (string) (($before['status'] ?? '').'/'.($before['outcome'] ?? '')), + 'after' => (string) (($after['status'] ?? '').'/'.($after['outcome'] ?? '')), + ]; + } + + $this->table( + ['applied', 'operation_run_id', 'type', 'source_id', 'before', 'after'], + $rows, + ); + + return self::SUCCESS; + } catch (Throwable $e) { + $this->error('Reconciliation failed: '.$e->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/app/Console/Commands/TenantpilotPurgeNonPersistentData.php b/app/Console/Commands/TenantpilotPurgeNonPersistentData.php index 4b35693..ba153a1 100644 --- a/app/Console/Commands/TenantpilotPurgeNonPersistentData.php +++ b/app/Console/Commands/TenantpilotPurgeNonPersistentData.php @@ -7,7 +7,7 @@ use App\Models\BackupSchedule; use App\Models\BackupScheduleRun; use App\Models\BackupSet; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\RestoreRun; @@ -88,7 +88,7 @@ public function handle(): int ->where('tenant_id', $tenant->id) ->delete(); - BulkOperationRun::query() + OperationRun::query() ->where('tenant_id', $tenant->id) ->delete(); @@ -152,7 +152,7 @@ private function countsForTenant(Tenant $tenant): array return [ 'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(), 'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(), - 'bulk_operation_runs' => BulkOperationRun::query()->where('tenant_id', $tenant->id)->count(), + 'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(), 'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(), 'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(), 'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(), diff --git a/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php b/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php index 360439a..d9c1860 100644 --- a/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php +++ b/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php @@ -5,8 +5,8 @@ use App\Models\BackupScheduleRun; use App\Models\OperationRun; use App\Models\Tenant; -use App\Services\BulkOperationService; use App\Services\OperationRunService; +use App\Support\OpsUx\RunFailureSanitizer; use Illuminate\Console\Command; class TenantpilotReconcileBackupScheduleOperationRuns extends Command @@ -18,7 +18,7 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.'; - public function handle(OperationRunService $operationRunService, BulkOperationService $bulkOperationService): int + public function handle(OperationRunService $operationRunService): int { $tenantIdentifiers = array_values(array_filter((array) $this->option('tenant'))); $olderThanMinutes = max(0, (int) $this->option('older-than')); @@ -70,8 +70,8 @@ public function handle(OperationRunService $operationRunService, BulkOperationSe outcome: 'failed', failures: [ [ - 'code' => 'RUN_NOT_FOUND', - 'message' => $bulkOperationService->sanitizeFailureReason('Backup schedule run not found.'), + 'code' => 'backup_schedule_run.not_found', + 'message' => RunFailureSanitizer::sanitizeMessage('Backup schedule run not found.'), ], ], ); @@ -99,31 +99,45 @@ public function handle(OperationRunService $operationRunService, BulkOperationSe $outcome = match ($scheduleRun->status) { BackupScheduleRun::STATUS_SUCCESS => 'succeeded', BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded', - BackupScheduleRun::STATUS_SKIPPED, - BackupScheduleRun::STATUS_CANCELED => 'cancelled', + BackupScheduleRun::STATUS_SKIPPED => 'succeeded', + BackupScheduleRun::STATUS_CANCELED => 'failed', default => 'failed', }; $summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : []; $syncFailures = $summary['sync_failures'] ?? []; - $summaryCounts = [ - 'backup_schedule_id' => (int) $scheduleRun->backup_schedule_id, - 'backup_schedule_run_id' => (int) $scheduleRun->getKey(), - 'backup_set_id' => $scheduleRun->backup_set_id ? (int) $scheduleRun->backup_set_id : null, - 'policies_total' => (int) ($summary['policies_total'] ?? 0), - 'policies_backed_up' => (int) ($summary['policies_backed_up'] ?? 0), - 'sync_failures' => is_array($syncFailures) ? count($syncFailures) : 0, - ]; + $policiesTotal = (int) ($summary['policies_total'] ?? 0); + $policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0); + $syncFailuresCount = is_array($syncFailures) ? count($syncFailures) : 0; - $summaryCounts = array_filter($summaryCounts, fn (mixed $value): bool => $value !== null); + $processed = $policiesBackedUp + $syncFailuresCount; + if ($policiesTotal > 0) { + $processed = min($policiesTotal, $processed); + } + + $summaryCounts = array_filter([ + 'total' => $policiesTotal, + 'processed' => $processed, + 'succeeded' => $policiesBackedUp, + 'failed' => $syncFailuresCount, + 'skipped' => $scheduleRun->status === BackupScheduleRun::STATUS_SKIPPED ? 1 : 0, + 'items' => $policiesTotal, + ], fn (mixed $value): bool => is_int($value) && $value !== 0); $failures = []; + if ($scheduleRun->status === BackupScheduleRun::STATUS_CANCELED) { + $failures[] = [ + 'code' => 'backup_schedule_run.cancelled', + 'message' => 'Backup schedule run was cancelled.', + ]; + } + if (filled($scheduleRun->error_message) || filled($scheduleRun->error_code)) { $failures[] = [ - 'code' => (string) ($scheduleRun->error_code ?: 'BACKUP_SCHEDULE_ERROR'), - 'message' => $bulkOperationService->sanitizeFailureReason((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')), + 'code' => (string) ($scheduleRun->error_code ?: 'backup_schedule_run.error'), + 'message' => RunFailureSanitizer::sanitizeMessage((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')), ]; } @@ -151,8 +165,8 @@ public function handle(OperationRunService $operationRunService, BulkOperationSe } $failures[] = [ - 'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR', - 'message' => $bulkOperationService->sanitizeFailureReason($message), + 'code' => $status !== null ? "graph.http_{$status}" : 'graph.error', + 'message' => RunFailureSanitizer::sanitizeMessage($message), ]; } } diff --git a/app/Filament/Pages/DriftLanding.php b/app/Filament/Pages/DriftLanding.php index 941abc2..7c9c3fa 100644 --- a/app/Filament/Pages/DriftLanding.php +++ b/app/Filament/Pages/DriftLanding.php @@ -5,19 +5,17 @@ use App\Filament\Resources\FindingResource; use App\Filament\Resources\InventorySyncRunResource; use App\Jobs\GenerateDriftFindingsJob; -use App\Models\BulkOperationRun; use App\Models\Finding; use App\Models\InventorySyncRun; use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; -use App\Services\BulkOperationService; use App\Services\Drift\DriftRunSelector; use App\Services\OperationRunService; +use App\Services\Operations\BulkSelectionIdentity; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; -use App\Support\RunIdempotency; use BackedEnum; use Filament\Actions\Action; use Filament\Notifications\Notification; @@ -50,8 +48,6 @@ class DriftLanding extends Page public ?int $operationRunId = null; - public ?int $bulkOperationRunId = null; - /** @var array|null */ public ?array $statusCounts = null; @@ -118,17 +114,6 @@ public function mount(): void $this->operationRunId = (int) $existingOperationRun->getKey(); } - $idempotencyKey = RunIdempotency::buildKey( - tenantId: (int) $tenant->getKey(), - operationType: 'drift.generate', - targetId: $scopeKey, - context: [ - 'scope_key' => $scopeKey, - 'baseline_run_id' => (int) $baseline->getKey(), - 'current_run_id' => (int) $current->getKey(), - ], - ); - $exists = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) @@ -153,48 +138,39 @@ public function mount(): void return; } - $latestRun = BulkOperationRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('idempotency_key', $idempotencyKey) - ->latest('id') - ->first(); + $existingOperationRun?->refresh(); - $activeRun = RunIdempotency::findActiveBulkOperationRun((int) $tenant->getKey(), $idempotencyKey); - if ($activeRun instanceof BulkOperationRun) { + if ($existingOperationRun instanceof OperationRun + && in_array($existingOperationRun->status, ['queued', 'running'], true) + ) { $this->state = 'generating'; - $this->bulkOperationRunId = (int) $activeRun->getKey(); + $this->operationRunId = (int) $existingOperationRun->getKey(); return; } - if ($latestRun instanceof BulkOperationRun && $latestRun->status === 'completed') { - $this->state = 'ready'; - $this->bulkOperationRunId = (int) $latestRun->getKey(); + if ($existingOperationRun instanceof OperationRun + && $existingOperationRun->status === 'completed' + ) { + $counts = is_array($existingOperationRun->summary_counts ?? null) ? $existingOperationRun->summary_counts : []; + $created = (int) ($counts['created'] ?? 0); - $newCount = (int) Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->where('scope_key', $scopeKey) - ->where('baseline_run_id', $baseline->getKey()) - ->where('current_run_id', $current->getKey()) - ->where('status', Finding::STATUS_NEW) - ->count(); + if ($existingOperationRun->outcome === 'failed') { + $this->state = 'error'; + $this->message = 'Drift generation failed for this comparison. See the run for details.'; + $this->operationRunId = (int) $existingOperationRun->getKey(); - $this->statusCounts = [Finding::STATUS_NEW => $newCount]; - - if ($newCount === 0) { - $this->message = 'No drift findings for this comparison. If you changed settings after the current run, run Inventory Sync again to capture a newer snapshot.'; + return; } - return; - } + if ($created === 0) { + $this->state = 'ready'; + $this->statusCounts = [Finding::STATUS_NEW => 0]; + $this->message = 'No drift findings for this comparison. If you changed settings after the current run, run Inventory Sync again to capture a newer snapshot.'; + $this->operationRunId = (int) $existingOperationRun->getKey(); - if ($latestRun instanceof BulkOperationRun && in_array($latestRun->status, ['failed', 'aborted'], true)) { - $this->state = 'error'; - $this->message = 'Drift generation failed for this comparison. See the run for details.'; - $this->bulkOperationRunId = (int) $latestRun->getKey(); - - return; + return; + } } if (! $user->canSyncTenant($tenant)) { @@ -204,73 +180,60 @@ public function mount(): void return; } - // --- Phase 3: Canonical Operation Run Start --- + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromQuery([ + 'scope_key' => $scopeKey, + 'baseline_run_id' => (int) $baseline->getKey(), + 'current_run_id' => (int) $current->getKey(), + ]); + /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( + + $opRun = $opService->enqueueBulkOperation( tenant: $tenant, type: 'drift.generate', - inputs: [ + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $user, $baseline, $current, $scopeKey): void { + GenerateDriftFindingsJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + baselineRunId: (int) $baseline->getKey(), + currentRunId: (int) $current->getKey(), + scopeKey: $scopeKey, + operationRun: $operationRun, + ); + }, + initiator: $user, + extraContext: [ 'scope_key' => $scopeKey, 'baseline_run_id' => (int) $baseline->getKey(), 'current_run_id' => (int) $current->getKey(), ], - initiator: $user + emitQueuedNotification: false, ); $this->operationRunId = (int) $opRun->getKey(); + $this->state = 'generating'; - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { - $this->state = 'generating'; // Reflect generating state in UI if idempotency hit - // Optionally, we could find the related BulkOpRun to link, but the UI might just need state. - + if (! $opRun->wasRecentlyCreated) { Notification::make() ->title('Drift generation already active') ->body('This operation is already queued or running.') ->warning() ->actions([ Action::make('view_run') - ->label('View Run') + ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->send(); return; } - // ---------------------------------------------- - - $bulkOperationService = app(BulkOperationService::class); - $run = $bulkOperationService->createRun( - tenant: $tenant, - user: $user, - resource: 'drift', - action: 'generate', - itemIds: [ - 'scope_key' => $scopeKey, - 'baseline_run_id' => (int) $baseline->getKey(), - 'current_run_id' => (int) $current->getKey(), - ], - totalItems: 1, - ); - - $run->update(['idempotency_key' => $idempotencyKey]); - - $this->state = 'generating'; - $this->bulkOperationRunId = (int) $run->getKey(); - - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $baseline, $current, $scopeKey, $run, $opRun): void { - GenerateDriftFindingsJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - baselineRunId: (int) $baseline->getKey(), - currentRunId: (int) $current->getKey(), - scopeKey: $scopeKey, - bulkOperationRunId: (int) $run->getKey(), - operationRun: $opRun - ); - }); OpsUxBrowserEvents::dispatchRunEnqueued($this); OperationUxPresenter::queuedToast((string) $opRun->type) diff --git a/app/Filament/Pages/InventoryLanding.php b/app/Filament/Pages/InventoryLanding.php index caecbf5..d4fee81 100644 --- a/app/Filament/Pages/InventoryLanding.php +++ b/app/Filament/Pages/InventoryLanding.php @@ -8,7 +8,6 @@ use App\Models\InventorySyncRun; use App\Models\Tenant; use App\Models\User; -use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Inventory\InventorySyncService; use App\Services\OperationRunService; @@ -112,7 +111,7 @@ protected function getHeaderActions(): array return $user->canSyncTenant(Tenant::current()); }) - ->action(function (array $data, self $livewire, BulkOperationService $bulkOperationService, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { + ->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { $tenant = Tenant::current(); $user = auth()->user(); @@ -202,22 +201,12 @@ protected function getHeaderActions(): array $policyTypes = []; } - $bulkRun = $bulkOperationService->createRun( - tenant: $tenant, - user: $user, - resource: 'inventory', - action: 'sync', - itemIds: $policyTypes, - totalItems: count($policyTypes), - ); - $auditLogger->log( tenant: $tenant, action: 'inventory.sync.dispatched', context: [ 'metadata' => [ 'inventory_sync_run_id' => $run->id, - 'bulk_run_id' => $bulkRun->id, 'selection_hash' => $run->selection_hash, ], ], @@ -228,12 +217,11 @@ protected function getHeaderActions(): array resourceId: (string) $run->id, ); - $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $bulkRun, $opRun): void { + $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void { RunInventorySyncJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), inventorySyncRunId: (int) $run->id, - bulkRunId: (int) $bulkRun->getKey(), operationRun: $opRun ); }); diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index 9f1e051..a7d10c0 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -13,7 +13,6 @@ use App\Rules\SupportedPolicyTypesRule; use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\BackupScheduling\ScheduleTimeService; -use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Support\OperationRunLinks; @@ -397,23 +396,8 @@ public static function table(Table $table): Table ], ); - $bulkRunId = null; - - if ($userModel instanceof User) { - $bulkRunId = app(BulkOperationService::class) - ->createRun( - tenant: $tenant, - user: $userModel, - resource: 'backup_schedule', - action: 'run', - itemIds: [(string) $record->id], - totalItems: 1, - ) - ->id; - } - - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRunId, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId, $operationRun)); + $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); }); OpsUxBrowserEvents::dispatchRunEnqueued($livewire); @@ -536,23 +520,8 @@ public static function table(Table $table): Table ], ); - $bulkRunId = null; - - if ($userModel instanceof User) { - $bulkRunId = app(BulkOperationService::class) - ->createRun( - tenant: $tenant, - user: $userModel, - resource: 'backup_schedule', - action: 'retry', - itemIds: [(string) $record->id], - totalItems: 1, - ) - ->id; - } - - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRunId, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId, $operationRun)); + $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); }); OpsUxBrowserEvents::dispatchRunEnqueued($livewire); @@ -591,16 +560,6 @@ public static function table(Table $table): Table $operationRunService = app(OperationRunService::class); $bulkRun = null; - if ($user) { - $bulkRun = app(\App\Services\BulkOperationService::class)->createRun( - tenant: $tenant, - user: $user, - resource: 'backup_schedule', - action: 'run', - itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(), - totalItems: $records->count(), - ); - } $createdRunIds = []; @@ -678,13 +637,12 @@ public static function table(Table $table): Table 'backup_schedule_run_id' => $run->id, 'scheduled_for' => $scheduledFor->toDateTimeString(), 'trigger' => 'bulk_run_now', - 'bulk_run_id' => $bulkRun?->id, ], ], ); - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun)); + $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); }, emitQueuedNotification: false); } @@ -731,16 +689,6 @@ public static function table(Table $table): Table $operationRunService = app(OperationRunService::class); $bulkRun = null; - if ($user) { - $bulkRun = app(\App\Services\BulkOperationService::class)->createRun( - tenant: $tenant, - user: $user, - resource: 'backup_schedule', - action: 'retry', - itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(), - totalItems: $records->count(), - ); - } $createdRunIds = []; @@ -818,13 +766,12 @@ public static function table(Table $table): Table 'backup_schedule_run_id' => $run->id, 'scheduled_for' => $scheduledFor->toDateTimeString(), 'trigger' => 'bulk_retry', - 'bulk_run_id' => $bulkRun?->id, ], ], ); - $operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void { - Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun)); + $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void { + Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun)); }, emitQueuedNotification: false); } diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index b60236f..178cd19 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -9,9 +9,12 @@ use App\Jobs\BulkBackupSetRestoreJob; use App\Models\BackupSet; use App\Models\Tenant; -use App\Services\BulkOperationService; +use App\Models\User; use App\Services\Intune\AuditLogger; use App\Services\Intune\BackupService; +use App\Services\OperationRunService; +use App\Services\Operations\BulkSelectionIdentity; +use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use BackedEnum; use Filament\Actions; @@ -198,22 +201,48 @@ public static function table(Table $table): Table $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) { - OperationUxPresenter::queuedToast('backup_set.delete') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), - ]) - ->send(); - - BulkBackupSetDeleteJob::dispatch($run->id); - } else { - BulkBackupSetDeleteJob::dispatchSync($run->id); + if (! $tenant instanceof Tenant) { + return; } + + $initiator = $user instanceof User ? $user : null; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'backup_set.delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkBackupSetDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + backupSetIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'backup_set_count' => $count, + ], + emitQueuedNotification: false, + ); + + OperationUxPresenter::queuedToast('backup_set.delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); }) ->deselectRecordsAfterCompletion(), @@ -238,22 +267,48 @@ public static function table(Table $table): Table $count = $records->count(); $ids = $records->pluck('id')->toArray(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'backup_set', 'restore', $ids, $count); - - if ($count >= 10) { - OperationUxPresenter::queuedToast('backup_set.restore') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), - ]) - ->send(); - - BulkBackupSetRestoreJob::dispatch($run->id); - } else { - BulkBackupSetRestoreJob::dispatchSync($run->id); + if (! $tenant instanceof Tenant) { + return; } + + $initiator = $user instanceof User ? $user : null; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'backup_set.restore', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkBackupSetRestoreJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + backupSetIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'backup_set_count' => $count, + ], + emitQueuedNotification: false, + ); + + OperationUxPresenter::queuedToast('backup_set.restore') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); }) ->deselectRecordsAfterCompletion(), @@ -293,22 +348,48 @@ public static function table(Table $table): Table $count = $records->count(); $ids = $records->pluck('id')->toArray(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'backup_set', 'force_delete', $ids, $count); - - if ($count >= 10) { - OperationUxPresenter::queuedToast('backup_set.force_delete') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), - ]) - ->send(); - - BulkBackupSetForceDeleteJob::dispatch($run->id); - } else { - BulkBackupSetForceDeleteJob::dispatchSync($run->id); + if (! $tenant instanceof Tenant) { + return; } + + $initiator = $user instanceof User ? $user : null; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'backup_set.force_delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkBackupSetForceDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + backupSetIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'backup_set_count' => $count, + ], + emitQueuedNotification: false, + ); + + OperationUxPresenter::queuedToast('backup_set.force_delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); }) ->deselectRecordsAfterCompletion(), ]), diff --git a/app/Filament/Resources/BulkOperationRunResource.php b/app/Filament/Resources/BulkOperationRunResource.php deleted file mode 100644 index 87a0678..0000000 --- a/app/Filament/Resources/BulkOperationRunResource.php +++ /dev/null @@ -1,388 +0,0 @@ -schema([ - Section::make('Legacy run view') - ->description('Canonical monitoring is now available in Monitoring → Operations.') - ->schema([ - TextEntry::make('canonical_view') - ->label('Canonical view') - ->state('View in Operations') - ->url(fn (BulkOperationRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant)) - ->badge() - ->color('primary'), - ]) - ->columnSpanFull(), - - Section::make('Run') - ->schema([ - TextEntry::make('user.name') - ->label('Initiator') - ->placeholder('—'), - TextEntry::make('resource')->badge(), - TextEntry::make('action')->badge(), - TextEntry::make('status') - ->label('Outcome') - ->badge() - ->state(fn (BulkOperationRun $record): string => $record->statusBucket()) - ->color(fn (BulkOperationRun $record): string => static::statusBucketColor($record->statusBucket())), - TextEntry::make('total_items')->label('Total')->numeric(), - TextEntry::make('processed_items')->label('Processed')->numeric(), - TextEntry::make('succeeded')->numeric(), - TextEntry::make('failed')->numeric(), - TextEntry::make('skipped')->numeric(), - TextEntry::make('created_at')->dateTime(), - TextEntry::make('updated_at')->dateTime(), - TextEntry::make('idempotency_key')->label('Idempotency key')->copyable()->placeholder('—'), - ]) - ->columns(2) - ->columnSpanFull(), - - Section::make('Related') - ->schema([ - TextEntry::make('related_backup_set') - ->label('Backup set') - ->state(function (BulkOperationRun $record): ?string { - $backupSetId = static::backupSetIdFromItemIds($record); - - if (! $backupSetId) { - return null; - } - - return "#{$backupSetId}"; - }) - ->url(function (BulkOperationRun $record): ?string { - $backupSetId = static::backupSetIdFromItemIds($record); - - if (! $backupSetId) { - return null; - } - - return BackupSetResource::getUrl('view', ['record' => $backupSetId], tenant: Tenant::current()); - }) - ->visible(fn (BulkOperationRun $record): bool => static::backupSetIdFromItemIds($record) !== null) - ->placeholder('—') - ->columnSpanFull(), - TextEntry::make('related_drift_findings') - ->label('Drift findings') - ->state('View') - ->url(function (BulkOperationRun $record): ?string { - if ($record->runType() !== 'drift.generate') { - return null; - } - - $payload = $record->item_ids ?? []; - if (! is_array($payload)) { - return FindingResource::getUrl('index', tenant: Tenant::current()); - } - - $scopeKey = null; - $baselineRunId = null; - $currentRunId = null; - - if (array_is_list($payload) && isset($payload[0]) && is_string($payload[0])) { - $scopeKey = $payload[0]; - } else { - $scopeKey = is_string($payload['scope_key'] ?? null) ? $payload['scope_key'] : null; - - if (is_numeric($payload['baseline_run_id'] ?? null)) { - $baselineRunId = (int) $payload['baseline_run_id']; - } - - if (is_numeric($payload['current_run_id'] ?? null)) { - $currentRunId = (int) $payload['current_run_id']; - } - } - - $tableFilters = []; - - if (is_string($scopeKey) && $scopeKey !== '') { - $tableFilters['scope_key'] = ['scope_key' => $scopeKey]; - } - - if (is_int($baselineRunId) || is_int($currentRunId)) { - $tableFilters['run_ids'] = [ - 'baseline_run_id' => $baselineRunId, - 'current_run_id' => $currentRunId, - ]; - } - - $parameters = $tableFilters !== [] ? ['tableFilters' => $tableFilters] : []; - - return FindingResource::getUrl('index', $parameters, tenant: Tenant::current()); - }) - ->visible(fn (BulkOperationRun $record): bool => $record->runType() === 'drift.generate') - ->placeholder('—') - ->columnSpanFull(), - ]) - ->visible(fn (BulkOperationRun $record): bool => in_array($record->runType(), ['backup_set.add_policies', 'drift.generate'], true)) - ->columnSpanFull(), - - Section::make('Items') - ->schema([ - ViewEntry::make('item_ids') - ->label('') - ->view('filament.infolists.entries.snapshot-json') - ->state(fn (BulkOperationRun $record) => $record->item_ids ?? []) - ->columnSpanFull(), - ]) - ->columnSpanFull(), - - Section::make('Failures') - ->schema([ - ViewEntry::make('failures') - ->label('') - ->view('filament.infolists.entries.snapshot-json') - ->state(fn (BulkOperationRun $record) => $record->failures ?? []) - ->columnSpanFull(), - ]) - ->columnSpanFull(), - ]); - } - - public static function table(Table $table): Table - { - return $table - ->defaultSort('id', 'desc') - ->modifyQueryUsing(function (Builder $query): Builder { - $tenantId = Tenant::current()->getKey(); - - return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId)); - }) - ->columns([ - Tables\Columns\TextColumn::make('user.name') - ->label('Initiator') - ->placeholder('—') - ->toggleable(), - Tables\Columns\TextColumn::make('resource')->badge(), - Tables\Columns\TextColumn::make('action')->badge(), - Tables\Columns\TextColumn::make('status') - ->label('Outcome') - ->badge() - ->formatStateUsing(fn (BulkOperationRun $record): string => $record->statusBucket()) - ->color(fn (BulkOperationRun $record): string => static::statusBucketColor($record->statusBucket())), - Tables\Columns\TextColumn::make('created_at')->since(), - Tables\Columns\TextColumn::make('total_items')->label('Total')->numeric(), - Tables\Columns\TextColumn::make('processed_items')->label('Processed')->numeric(), - Tables\Columns\TextColumn::make('failed')->numeric(), - ]) - ->filters([ - Tables\Filters\SelectFilter::make('run_type') - ->label('Run type') - ->options(fn (): array => static::runTypeOptions()) - ->query(function (Builder $query, array $data): Builder { - $value = $data['value'] ?? null; - - if (! is_string($value) || $value === '' || ! str_contains($value, '.')) { - return $query; - } - - [$resource, $action] = explode('.', $value, 2); - - if ($resource === '' || $action === '') { - return $query; - } - - return $query - ->where('resource', $resource) - ->where('action', $action); - }), - Tables\Filters\SelectFilter::make('status_bucket') - ->label('Status') - ->options([ - 'queued' => 'Queued', - 'running' => 'Running', - 'succeeded' => 'Succeeded', - 'partially succeeded' => 'Partially succeeded', - 'failed' => 'Failed', - ]) - ->query(function (Builder $query, array $data): Builder { - $value = $data['value'] ?? null; - - if (! is_string($value) || $value === '') { - return $query; - } - - $nonSkippedFailureSql = "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(failures, '[]'::jsonb)) AS elem WHERE (elem->>'type' IS NULL OR elem->>'type' <> 'skipped'))"; - - return match ($value) { - 'queued' => $query->where('status', 'pending'), - 'running' => $query->where('status', 'running'), - 'succeeded' => $query - ->whereIn('status', ['completed', 'completed_with_errors']) - ->where('failed', 0) - ->whereRaw("NOT {$nonSkippedFailureSql}"), - 'partially succeeded' => $query - ->whereNotIn('status', ['pending', 'running']) - ->where('succeeded', '>', 0) - ->where(function (Builder $q) use ($nonSkippedFailureSql): void { - $q->where('failed', '>', 0)->orWhereRaw($nonSkippedFailureSql); - }), - 'failed' => $query - ->whereNotIn('status', ['pending', 'running']) - ->where(function (Builder $q) use ($nonSkippedFailureSql): void { - $q->where(function (Builder $q) use ($nonSkippedFailureSql): void { - $q->where('succeeded', 0) - ->where(function (Builder $q) use ($nonSkippedFailureSql): void { - $q->where('failed', '>', 0)->orWhereRaw($nonSkippedFailureSql); - }); - })->orWhere(function (Builder $q) use ($nonSkippedFailureSql): void { - $q->whereIn('status', ['failed', 'aborted']) - ->whereNot(function (Builder $q) use ($nonSkippedFailureSql): void { - $q->where('succeeded', '>', 0) - ->where(function (Builder $q) use ($nonSkippedFailureSql): void { - $q->where('failed', '>', 0)->orWhereRaw($nonSkippedFailureSql); - }); - }); - }); - }), - default => $query, - }; - }), - Tables\Filters\Filter::make('created_at') - ->label('Created') - ->form([ - DatePicker::make('created_from') - ->label('From') - ->default(fn () => now()->subDays(30)), - DatePicker::make('created_until') - ->label('Until') - ->default(fn () => now()), - ]) - ->query(function (Builder $query, array $data): Builder { - $from = $data['created_from'] ?? null; - if ($from) { - $query->whereDate('created_at', '>=', $from); - } - - $until = $data['created_until'] ?? null; - if ($until) { - $query->whereDate('created_at', '<=', $until); - } - - return $query; - }), - ]) - ->actions([ - Actions\ViewAction::make(), - ]) - ->bulkActions([]); - } - - public static function getEloquentQuery(): Builder - { - return parent::getEloquentQuery() - ->with('user') - ->latest('id'); - } - - public static function getPages(): array - { - return [ - 'index' => Pages\ListBulkOperationRuns::route('/'), - 'view' => Pages\ViewBulkOperationRun::route('/{record}'), - ]; - } - - /** - * @return array - */ - private static function runTypeOptions(): array - { - $tenantId = Tenant::current()->getKey(); - - $knownTypes = [ - 'drift.generate' => 'drift.generate', - 'backup_set.add_policies' => 'backup_set.add_policies', - ]; - - $storedTypes = BulkOperationRun::query() - ->where('tenant_id', $tenantId) - ->select(['resource', 'action']) - ->distinct() - ->orderBy('resource') - ->orderBy('action') - ->get() - ->mapWithKeys(function (BulkOperationRun $run): array { - $type = "{$run->resource}.{$run->action}"; - - return [$type => $type]; - }) - ->all(); - - return array_replace($storedTypes, $knownTypes); - } - - private static function statusBucketColor(string $statusBucket): string - { - return match ($statusBucket) { - 'succeeded' => 'success', - 'partially succeeded' => 'warning', - 'failed' => 'danger', - 'running' => 'info', - 'queued' => 'gray', - default => 'gray', - }; - } - - private static function backupSetIdFromItemIds(BulkOperationRun $record): ?int - { - if ($record->runType() !== 'backup_set.add_policies') { - return null; - } - - $payload = $record->item_ids ?? []; - if (! is_array($payload)) { - return null; - } - - $backupSetId = $payload['backup_set_id'] ?? null; - if (! is_numeric($backupSetId)) { - return null; - } - - $backupSetId = (int) $backupSetId; - - return $backupSetId > 0 ? $backupSetId : null; - } -} diff --git a/app/Filament/Resources/BulkOperationRunResource/Pages/ListBulkOperationRuns.php b/app/Filament/Resources/BulkOperationRunResource/Pages/ListBulkOperationRuns.php deleted file mode 100644 index ef4ea96..0000000 --- a/app/Filament/Resources/BulkOperationRunResource/Pages/ListBulkOperationRuns.php +++ /dev/null @@ -1,11 +0,0 @@ -getKey(); + + return parent::getEloquentQuery() + ->with('user') + ->latest('id') + ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)); + } + public static function form(Schema $schema): Schema { return $schema; @@ -58,6 +68,11 @@ public static function infolist(Schema $schema): Schema ->badge() ->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)), TextEntry::make('initiator_name')->label('Initiator'), + TextEntry::make('target_scope_display') + ->label('Target') + ->getStateUsing(fn (OperationRun $record): ?string => static::targetScopeDisplay($record)) + ->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) !== null) + ->columnSpanFull(), TextEntry::make('elapsed') ->label('Elapsed') ->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)), @@ -238,13 +253,6 @@ public static function table(Table $table): Table ->bulkActions([]); } - public static function getEloquentQuery(): Builder - { - return parent::getEloquentQuery() - ->with('user') - ->latest('id'); - } - public static function getPages(): array { return [ @@ -273,4 +281,42 @@ private static function outcomeColor(?string $outcome): string default => 'gray', }; } + + private static function targetScopeDisplay(OperationRun $record): ?string + { + $context = is_array($record->context) ? $record->context : []; + + $targetScope = $context['target_scope'] ?? null; + if (! is_array($targetScope)) { + return null; + } + + $entraTenantName = $targetScope['entra_tenant_name'] ?? null; + $entraTenantId = $targetScope['entra_tenant_id'] ?? null; + $directoryContextId = $targetScope['directory_context_id'] ?? null; + + $entraTenantName = is_string($entraTenantName) ? trim($entraTenantName) : null; + $entraTenantId = is_string($entraTenantId) ? trim($entraTenantId) : null; + + $directoryContextId = match (true) { + is_string($directoryContextId) => trim($directoryContextId), + is_int($directoryContextId) => (string) $directoryContextId, + default => null, + }; + + $entra = null; + + if ($entraTenantName !== null && $entraTenantName !== '') { + $entra = $entraTenantId ? "{$entraTenantName} ({$entraTenantId})" : $entraTenantName; + } elseif ($entraTenantId !== null && $entraTenantId !== '') { + $entra = $entraTenantId; + } + + $parts = array_values(array_filter([ + $entra, + $directoryContextId ? "directory_context_id: {$directoryContextId}" : null, + ], fn (?string $value): bool => $value !== null && $value !== '')); + + return $parts !== [] ? implode(' · ', $parts) : null; + } } diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index b55c53d..4a2ef0a 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -11,9 +11,9 @@ use App\Models\Policy; use App\Models\Tenant; use App\Models\User; -use App\Services\BulkOperationService; use App\Services\Intune\PolicyNormalizer; use App\Services\OperationRunService; +use App\Services\Operations\BulkSelectionIdentity; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -391,7 +391,7 @@ public static function table(Table $table): Table return $user->canSyncTenant($tenant); }) - ->action(function (Policy $record) { + ->action(function (Policy $record, HasTable $livewire): void { $tenant = Tenant::current(); $user = auth()->user(); @@ -458,10 +458,51 @@ public static function table(Table $table): Table $tenant = Tenant::current(); $user = auth()->user(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'export', [$record->id], 1); + if (! $user instanceof User) { + abort(403); + } - BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']); + $ids = [(int) $record->getKey()]; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy.export', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data): void { + BulkPolicyExportJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + backupName: (string) $data['backup_name'], + operationRun: $operationRun, + ); + }, + initiator: $user, + extraContext: [ + 'backup_name' => (string) $data['backup_name'], + 'policy_count' => 1, + ], + emitQueuedNotification: false, + ); + + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); }), ])->icon('heroicon-o-ellipsis-vertical'), ]) @@ -499,24 +540,54 @@ public static function table(Table $table): Table $count = $records->count(); $ids = $records->pluck('id')->toArray(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'delete', $ids, $count); - - if ($count >= 20) { - Notification::make() - ->title('Bulk delete started') - ->body("Deleting {$count} policies 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(); - - BulkPolicyDeleteJob::dispatch($run->id); - } else { - BulkPolicyDeleteJob::dispatchSync($run->id); + if (! $user instanceof User) { + return; } + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy.delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $user, $ids): void { + BulkPolicyDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $user, + extraContext: [ + 'policy_count' => $count, + ], + emitQueuedNotification: false, + ); + + Notification::make() + ->title('Policy delete queued') + ->body("Queued deletion for {$count} policies.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->actions([ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->duration(8000) + ->sendToDatabase($user) + ->send(); }) ->deselectRecordsAfterCompletion(), @@ -537,8 +608,50 @@ public static function table(Table $table): Table $count = $records->count(); $ids = $records->pluck('id')->toArray(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'unignore', $ids, $count); + if (! $user instanceof User) { + abort(403); + } + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy.unignore', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void { + if ($count >= 20) { + BulkPolicyUnignoreJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + operationRun: $operationRun, + ); + + return; + } + + BulkPolicyUnignoreJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $user, + extraContext: [ + 'policy_count' => $count, + ], + emitQueuedNotification: false, + ); if ($count >= 20) { Notification::make() @@ -550,11 +663,17 @@ public static function table(Table $table): Table ->duration(8000) ->sendToDatabase($user) ->send(); - - BulkPolicyUnignoreJob::dispatch($run->id); - } else { - BulkPolicyUnignoreJob::dispatchSync($run->id); } + + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); }) ->deselectRecordsAfterCompletion(), @@ -581,7 +700,7 @@ public static function table(Table $table): Table return $value === 'ignored'; }) - ->action(function (Collection $records) { + ->action(function (Collection $records, HasTable $livewire): void { $tenant = Tenant::current(); $user = auth()->user(); $count = $records->count(); @@ -660,8 +779,53 @@ public static function table(Table $table): Table $count = $records->count(); $ids = $records->pluck('id')->toArray(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'export', $ids, $count); + if (! $user instanceof User) { + abort(403); + } + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy.export', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void { + if ($count >= 20) { + BulkPolicyExportJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + backupName: (string) $data['backup_name'], + operationRun: $operationRun, + ); + + return; + } + + BulkPolicyExportJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $ids, + backupName: (string) $data['backup_name'], + operationRun: $operationRun, + ); + }, + initiator: $user, + extraContext: [ + 'backup_name' => (string) $data['backup_name'], + 'policy_count' => $count, + ], + emitQueuedNotification: false, + ); if ($count >= 20) { Notification::make() @@ -673,11 +837,15 @@ public static function table(Table $table): Table ->duration(8000) ->sendToDatabase($user) ->send(); - - BulkPolicyExportJob::dispatch($run->id, $data['backup_name']); - } else { - BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']); } + + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); }) ->deselectRecordsAfterCompletion(), ]), diff --git a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php index f1a7f0b..699b86d 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php +++ b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php @@ -2,12 +2,12 @@ namespace App\Filament\Resources\PolicyResource\Pages; -use App\Filament\Resources\BulkOperationRunResource; use App\Filament\Resources\PolicyResource; use App\Jobs\CapturePolicySnapshotJob; -use App\Services\BulkOperationService; +use App\Services\OperationRunService; +use App\Services\Operations\BulkSelectionIdentity; +use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; -use App\Support\RunIdempotency; use Filament\Actions\Action; use Filament\Forms; use Filament\Notifications\Notification; @@ -54,64 +54,66 @@ protected function getActions(): array return; } - $idempotencyKey = RunIdempotency::buildKey( - tenantId: $tenant->getKey(), - operationType: 'policy.capture_snapshot', - targetId: $policy->getKey() + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds([(string) $policy->getKey()]); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy.capture_snapshot', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $policy, $user, $data): void { + CapturePolicySnapshotJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyId: (int) $policy->getKey(), + includeAssignments: (bool) ($data['include_assignments'] ?? false), + includeScopeTags: (bool) ($data['include_scope_tags'] ?? false), + createdBy: $user->email ? Str::limit($user->email, 255, '') : null, + operationRun: $operationRun, + context: [], + ); + }, + initiator: $user, + extraContext: [ + 'policy_id' => (int) $policy->getKey(), + 'include_assignments' => (bool) ($data['include_assignments'] ?? false), + 'include_scope_tags' => (bool) ($data['include_scope_tags'] ?? false), + ], + emitQueuedNotification: false, ); - $existingRun = RunIdempotency::findActiveBulkOperationRun( - tenantId: $tenant->getKey(), - idempotencyKey: $idempotencyKey - ); - - if ($existingRun) { + if (! $opRun->wasRecentlyCreated) { Notification::make() ->title('Snapshot already in progress') ->body('An active run already exists for this policy. Opening run details.') ->actions([ \Filament\Actions\Action::make('view_run') ->label('View run') - ->url(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)), + ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->info() ->send(); - $this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)); + $this->redirect(OperationRunLinks::view($opRun, $tenant)); return; } - - $bulkOperationService = app(BulkOperationService::class); - - $run = $bulkOperationService->createRun( - tenant: $tenant, - user: $user, - resource: 'policies', - action: 'capture_snapshot', - itemIds: [(string) $policy->getKey()], - totalItems: 1 - ); - - $run->update(['idempotency_key' => $idempotencyKey]); - - CapturePolicySnapshotJob::dispatch( - bulkOperationRunId: $run->getKey(), - policyId: $policy->getKey(), - includeAssignments: (bool) ($data['include_assignments'] ?? false), - includeScopeTags: (bool) ($data['include_scope_tags'] ?? false), - createdBy: $user->email ? Str::limit($user->email, 255, '') : null - ); - OperationUxPresenter::queuedToast('policy.capture_snapshot') ->actions([ \Filament\Actions\Action::make('view_run') ->label('View run') - ->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), + ->url(OperationRunLinks::view($opRun, $tenant)), ]) ->send(); - $this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)); + $this->redirect(OperationRunLinks::view($opRun, $tenant)); }) ->color('primary'), ]; diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index f89b89a..19f0319 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -10,10 +10,13 @@ use App\Models\BackupSet; use App\Models\PolicyVersion; use App\Models\Tenant; -use App\Services\BulkOperationService; +use App\Models\User; use App\Services\Intune\AuditLogger; use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\VersionDiff; +use App\Services\OperationRunService; +use App\Services\Operations\BulkSelectionIdentity; +use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use BackedEnum; use Carbon\CarbonImmutable; @@ -406,22 +409,66 @@ public static function table(Table $table): Table $retentionDays = (int) ($data['retention_days'] ?? 90); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy_version', 'prune', $ids, $count); + if (! $tenant instanceof Tenant) { + return; + } - if ($count >= 20) { - OperationUxPresenter::queuedToast('policy_version.prune') + $initiator = $user instanceof User ? $user : null; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy_version.prune', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void { + BulkPolicyVersionPruneJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + policyVersionIds: $ids, + retentionDays: $retentionDays, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'policy_version_count' => $count, + 'retention_days' => $retentionDays, + ], + emitQueuedNotification: false, + ); + + if ($initiator instanceof User) { + Notification::make() + ->title('Policy version prune queued') + ->body("Queued prune for {$count} policy versions.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() ->actions([ Actions\Action::make('view_run') ->label('View run') - ->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), + ->url(OperationRunLinks::view($opRun, $tenant)), ]) - ->send(); - - BulkPolicyVersionPruneJob::dispatch($run->id, $retentionDays); - } else { - BulkPolicyVersionPruneJob::dispatchSync($run->id, $retentionDays); + ->duration(8000) + ->sendToDatabase($initiator); } + + OperationUxPresenter::queuedToast('policy_version.prune') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); }) ->deselectRecordsAfterCompletion(), @@ -446,22 +493,48 @@ public static function table(Table $table): Table $count = $records->count(); $ids = $records->pluck('id')->toArray(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy_version', 'restore', $ids, $count); - - if ($count >= 20) { - OperationUxPresenter::queuedToast('policy_version.restore') - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), - ]) - ->send(); - - BulkPolicyVersionRestoreJob::dispatch($run->id); - } else { - BulkPolicyVersionRestoreJob::dispatchSync($run->id); + if (! $tenant instanceof Tenant) { + return; } + + $initiator = $user instanceof User ? $user : null; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy_version.restore', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkPolicyVersionRestoreJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + policyVersionIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'policy_version_count' => $count, + ], + emitQueuedNotification: false, + ); + + OperationUxPresenter::queuedToast('policy_version.restore') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); }) ->deselectRecordsAfterCompletion(), @@ -495,22 +568,64 @@ public static function table(Table $table): Table $count = $records->count(); $ids = $records->pluck('id')->toArray(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', $ids, $count); + if (! $tenant instanceof Tenant) { + return; + } - if ($count >= 20) { - OperationUxPresenter::queuedToast('policy_version.force_delete') + $initiator = $user instanceof User ? $user : null; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy_version.force_delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkPolicyVersionForceDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + policyVersionIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'policy_version_count' => $count, + ], + emitQueuedNotification: false, + ); + + if ($initiator instanceof User) { + Notification::make() + ->title('Policy version force delete queued') + ->body("Queued force delete for {$count} policy versions.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() ->actions([ Actions\Action::make('view_run') ->label('View run') - ->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), + ->url(OperationRunLinks::view($opRun, $tenant)), ]) - ->send(); - - BulkPolicyVersionForceDeleteJob::dispatch($run->id); - } else { - BulkPolicyVersionForceDeleteJob::dispatchSync($run->id); + ->duration(8000) + ->sendToDatabase($initiator); } + + OperationUxPresenter::queuedToast('policy_version.force_delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); }) ->deselectRecordsAfterCompletion(), ]), diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 2d3d856..029a07c 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -12,17 +12,20 @@ use App\Models\EntraGroup; use App\Models\RestoreRun; use App\Models\Tenant; +use App\Models\User; use App\Rules\SkipOrUuidRule; -use App\Services\BulkOperationService; use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreDiffGenerator; use App\Services\Intune\RestoreRiskChecker; use App\Services\Intune\RestoreService; +use App\Services\OperationRunService; +use App\Services\Operations\BulkSelectionIdentity; +use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\RestoreRunIdempotency; use App\Support\RestoreRunStatus; -use App\Support\RunIdempotency; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -738,7 +741,8 @@ public static function table(Table $table): Table ->visible(function (RestoreRun $record): bool { $backupSet = $record->backupSet; - return $record->isDeletable() + return ! $record->trashed() + && $record->isDeletable() && $backupSet !== null && ! $backupSet->trashed(); }) @@ -751,10 +755,10 @@ public static function table(Table $table): Table $tenant = $record->tenant; $backupSet = $record->backupSet; - if (! $tenant || ! $backupSet || $backupSet->trashed()) { + if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) { Notification::make() ->title('Restore run cannot be rerun') - ->body('Backup set is archived or unavailable.') + ->body('Restore run or backup set is archived or unavailable.') ->warning() ->send(); @@ -780,14 +784,14 @@ public static function table(Table $table): Table 'rerun_of_restore_run_id' => $record->id, ]; - $idempotencyKey = RunIdempotency::restoreExecuteKey( + $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey( tenantId: (int) $tenant->getKey(), backupSetId: (int) $backupSet->getKey(), selectedItemIds: $selectedItemIds, groupMapping: $groupMapping, ); - $existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); + $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); if ($existing) { Notification::make() @@ -813,7 +817,7 @@ public static function table(Table $table): Table 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, ]); } catch (QueryException $exception) { - $existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); + $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); if ($existing) { Notification::make() @@ -1016,7 +1020,6 @@ public static function table(Table $table): Table return [ Forms\Components\TextInput::make('confirmation') ->label('Type DELETE to confirm') - ->required() ->in(['DELETE']) ->validationMessages([ 'in' => 'Please type DELETE to confirm.', @@ -1032,24 +1035,48 @@ public static function table(Table $table): Table $count = $records->count(); $ids = $records->pluck('id')->toArray(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'restore_run', 'delete', $ids, $count); - - if ($count >= 20) { - Notification::make() - ->title('Bulk delete started') - ->body("Deleting {$count} restore runs 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(); - - BulkRestoreRunDeleteJob::dispatch($run->id); - } else { - BulkRestoreRunDeleteJob::dispatchSync($run->id); + if (! $tenant instanceof Tenant) { + return; } + + $initiator = $user instanceof User ? $user : null; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'restore_run.delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void { + BulkRestoreRunDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + restoreRunIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'restore_run_count' => $count, + ], + emitQueuedNotification: false, + ); + + OperationUxPresenter::queuedToast('restore_run.delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); }) ->deselectRecordsAfterCompletion(), @@ -1074,24 +1101,59 @@ public static function table(Table $table): Table $count = $records->count(); $ids = $records->pluck('id')->toArray(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'restore_run', 'restore', $ids, $count); - - if ($count >= 20) { - Notification::make() - ->title('Bulk restore started') - ->body("Restoring {$count} restore runs 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(); - - BulkRestoreRunRestoreJob::dispatch($run->id); - } else { - BulkRestoreRunRestoreJob::dispatchSync($run->id); + if (! $tenant instanceof Tenant) { + return; } + + $initiator = $user instanceof User ? $user : null; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'restore_run.restore', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void { + if ($count >= 20) { + BulkRestoreRunRestoreJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + restoreRunIds: $ids, + operationRun: $operationRun, + ); + + return; + } + + BulkRestoreRunRestoreJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + restoreRunIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'restore_run_count' => $count, + ], + emitQueuedNotification: false, + ); + + OperationUxPresenter::queuedToast('restore_run.restore') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); }) ->deselectRecordsAfterCompletion(), @@ -1125,24 +1187,59 @@ public static function table(Table $table): Table $count = $records->count(); $ids = $records->pluck('id')->toArray(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'restore_run', 'force_delete', $ids, $count); - - if ($count >= 20) { - Notification::make() - ->title('Bulk force delete started') - ->body("Force deleting {$count} restore runs 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(); - - BulkRestoreRunForceDeleteJob::dispatch($run->id); - } else { - BulkRestoreRunForceDeleteJob::dispatchSync($run->id); + if (! $tenant instanceof Tenant) { + return; } + + $initiator = $user instanceof User ? $user : null; + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $opRun = $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'restore_run.force_delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void { + if ($count >= 20) { + BulkRestoreRunForceDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + restoreRunIds: $ids, + operationRun: $operationRun, + ); + + return; + } + + BulkRestoreRunForceDeleteJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + userId: (int) ($initiator?->getKey() ?? 0), + restoreRunIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $initiator, + extraContext: [ + 'restore_run_count' => $count, + ], + emitQueuedNotification: false, + ); + + OperationUxPresenter::queuedToast('restore_run.force_delete') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); }) ->deselectRecordsAfterCompletion(), ]), @@ -1484,14 +1581,14 @@ public static function createRestoreRun(array $data): RestoreRun $metadata['preview_ran_at'] = $previewRanAt; } - $idempotencyKey = RunIdempotency::restoreExecuteKey( + $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey( tenantId: (int) $tenant->getKey(), backupSetId: (int) $backupSet->getKey(), selectedItemIds: $selectedItemIds, groupMapping: $groupMapping, ); - $existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); + $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); if ($existing) { Notification::make() @@ -1517,7 +1614,7 @@ public static function createRestoreRun(array $data): RestoreRun 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, ]); } catch (QueryException $exception) { - $existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); + $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); if ($existing) { Notification::make() diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index ad6e67c..b5e120e 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -8,7 +8,6 @@ use App\Jobs\SyncPoliciesJob; use App\Models\Tenant; use App\Models\User; -use App\Services\BulkOperationService; use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Graph\GraphClientInterface; use App\Services\Intune\AuditLogger; @@ -17,6 +16,7 @@ use App\Services\Intune\TenantConfigService; use App\Services\Intune\TenantPermissionService; use App\Services\OperationRunService; +use App\Services\Operations\BulkSelectionIdentity; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -448,49 +448,42 @@ public static function table(Table $table): Table $ids = $eligible->pluck('id')->toArray(); $count = $eligible->count(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenantContext, $user, 'tenant', 'sync', $ids, $count); + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($ids); - foreach ($eligible as $tenant) { - // Note: We might want canonical runs for bulk syncs too, but spec Phase 1 mentions tenant-scoped operations. - // Bulk operation across tenants is a higher level concept. - // Keeping it as is for now or migrating individually. - // If we want each tenant sync to show in its Monitoring, we should create opRun for each. + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'policy.sync', - inputs: ['scope' => 'full', 'bulk_run_id' => $run->id], - initiator: $user - ); + $opRun = $runs->enqueueBulkOperation( + tenant: $tenantContext, + type: 'tenant.sync', + targetScope: [ + 'entra_tenant_id' => (string) ($tenantContext->tenant_id ?? $tenantContext->external_id), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenantContext, $user, $ids): void { + BulkTenantSyncJob::dispatch( + tenantId: (int) $tenantContext->getKey(), + userId: (int) $user->getKey(), + tenantIds: $ids, + operationRun: $operationRun, + ); + }, + initiator: $user, + extraContext: [ + 'tenant_count' => $count, + ], + emitQueuedNotification: false, + ); - SyncPoliciesJob::dispatch($tenant->getKey(), null, null, $opRun); - - $auditLogger->log( - tenant: $tenant, - action: 'tenant.sync_dispatched', - resourceType: 'tenant', - resourceId: (string) $tenant->id, - status: 'success', - context: ['metadata' => ['tenant_id' => $tenant->tenant_id]], - ); - } - - $count = $eligible->count(); - - Notification::make() - ->title('Bulk sync started') - ->body("Syncing {$count} tenant(s) in the background. Check the progress bar in the bottom right corner.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->success() - ->duration(8000) - ->sendToDatabase($user) + OperationUxPresenter::queuedToast('tenant.sync') + ->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenantContext)), + ]) ->send(); - - BulkTenantSyncJob::dispatch($run->id); }) ->deselectRecordsAfterCompletion(), ]) diff --git a/app/Jobs/AddPoliciesToBackupSetJob.php b/app/Jobs/AddPoliciesToBackupSetJob.php index 9a6b4d5..d1721eb 100644 --- a/app/Jobs/AddPoliciesToBackupSetJob.php +++ b/app/Jobs/AddPoliciesToBackupSetJob.php @@ -2,20 +2,19 @@ namespace App\Jobs; -use App\Filament\Resources\BulkOperationRunResource; use App\Jobs\Middleware\TrackOperationRun; use App\Models\BackupItem; use App\Models\BackupSet; -use App\Models\BulkOperationRun; use App\Models\OperationRun; use App\Models\Policy; use App\Models\Tenant; -use App\Services\BulkOperationService; +use App\Models\User; use App\Services\Intune\FoundationSnapshotService; use App\Services\Intune\PolicyCaptureOrchestrator; use App\Services\Intune\SnapshotValidator; use App\Services\OperationRunService; use App\Support\OperationRunLinks; +use App\Support\OpsUx\RunFailureSanitizer; use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -34,12 +33,17 @@ class AddPoliciesToBackupSetJob implements ShouldQueue public ?OperationRun $operationRun = null; + /** + * @param array $policyIds + * @param array{include_assignments?: bool, include_scope_tags?: bool, include_foundations?: bool} $options + */ public function __construct( - public int $bulkRunId, + public int $tenantId, + public int $userId, public int $backupSetId, - public bool $includeAssignments, - public bool $includeScopeTags, - public bool $includeFoundations, + public array $policyIds, + public array $options, + public string $idempotencyKey, ?OperationRun $operationRun = null ) { $this->operationRun = $operationRun; @@ -51,39 +55,31 @@ public function middleware(): array } public function handle( - BulkOperationService $bulkOperationService, + OperationRunService $operationRunService, PolicyCaptureOrchestrator $captureOrchestrator, FoundationSnapshotService $foundationSnapshots, SnapshotValidator $snapshotValidator, ): void { - $run = BulkOperationRun::with(['tenant', 'user'])->find($this->bulkRunId); - - if (! $run) { + if (! $this->operationRun instanceof OperationRun) { return; } - $started = BulkOperationRun::query() - ->whereKey($run->getKey()) - ->where('status', 'pending') - ->update(['status' => 'running']); + $tenant = Tenant::query()->find($this->tenantId); + $initiator = User::query()->find($this->userId); - if ($started === 0) { - return; - } - - $run->refresh(); - - $tenant = $run->tenant ?? Tenant::query()->find($run->tenant_id); + $policyIds = $this->normalizePolicyIds($this->policyIds); + $includeAssignments = (bool) ($this->options['include_assignments'] ?? false); + $includeScopeTags = (bool) ($this->options['include_scope_tags'] ?? false); + $includeFoundations = (bool) ($this->options['include_foundations'] ?? false); try { if (! $tenant instanceof Tenant) { - $this->markRunFailed( - bulkOperationService: $bulkOperationService, - run: $run, + $this->failRun( + operationRunService: $operationRunService, tenant: null, - itemId: (string) $this->backupSetId, - reasonCode: 'unknown', - reason: 'Tenant not found for run.', + code: 'tenant.not_found', + message: 'Tenant not found for run.', + initiator: $initiator, ); return; @@ -95,49 +91,64 @@ public function handle( ->first(); if (! $backupSet) { - $this->markRunFailed( - bulkOperationService: $bulkOperationService, - run: $run, + $this->failRun( + operationRunService: $operationRunService, tenant: $tenant, - itemId: (string) $this->backupSetId, - reasonCode: 'backup_set_not_found', - reason: 'Backup set not found.', + code: 'backup_set.not_found', + message: 'Backup set not found.', + initiator: $initiator, ); return; } if ($backupSet->trashed()) { - $this->markRunFailed( - bulkOperationService: $bulkOperationService, - run: $run, + $this->failRun( + operationRunService: $operationRunService, tenant: $tenant, - itemId: (string) $backupSet->getKey(), - reasonCode: 'backup_set_archived', - reason: 'Backup set is archived.', + code: 'backup_set.archived', + message: 'Backup set is archived.', + initiator: $initiator, ); return; } - $policyIds = $this->extractPolicyIds($run); + $this->operationRun->update([ + 'context' => array_merge($this->operationRun->context ?? [], [ + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_ids' => $policyIds, + 'options' => [ + 'include_assignments' => $includeAssignments, + 'include_scope_tags' => $includeScopeTags, + 'include_foundations' => $includeFoundations, + ], + 'idempotency_key' => $this->idempotencyKey, + ]), + ]); + + $operationRunService->updateRun($this->operationRun, 'running', 'pending'); + $operationRunService->incrementSummaryCounts($this->operationRun, [ + 'total' => count($policyIds), + 'items' => count($policyIds), + ]); if ($policyIds === []) { - $bulkOperationService->complete($run); + $operationRunService->updateRun( + $this->operationRun, + status: 'completed', + outcome: 'failed', + failures: [[ + 'code' => 'selection.empty', + 'message' => 'No policies selected.', + ]], + ); - if ($this->operationRun) { - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opService->updateRun($this->operationRun, 'completed', 'succeeded', ['policy_ids' => 0]); - } + $this->notifyRunFailed($initiator, $tenant, 'No policies selected.'); return; } - if ((int) $run->total_items !== count($policyIds)) { - $run->update(['total_items' => count($policyIds)]); - } - $existingBackupFailures = (array) Arr::get($backupSet->metadata ?? [], 'failures', []); $newBackupFailures = []; @@ -172,14 +183,14 @@ public function handle( ->get() ->keyBy('id'); + $runFailuresForOperationRun = []; + foreach ($policyIds as $policyId) { if (isset($activePolicyIdSet[$policyId])) { - $bulkOperationService->recordSkippedWithReason( - run: $run, - itemId: (string) $policyId, - reason: 'Already in backup set', - reasonCode: 'already_in_backup_set', - ); + $operationRunService->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); continue; } @@ -193,7 +204,11 @@ public function handle( $didMutateBackupSet = true; $backupSetItemMutations++; - $bulkOperationService->recordSuccess($run); + $operationRunService->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'succeeded' => 1, + 'updated' => 1, + ]); continue; } @@ -203,29 +218,30 @@ public function handle( if (! $policy instanceof Policy) { $newBackupFailures[] = [ 'policy_id' => $policyId, - 'reason' => $bulkOperationService->sanitizeFailureReason('Policy not found.'), + 'reason' => RunFailureSanitizer::sanitizeMessage('Policy not found.'), 'status' => null, 'reason_code' => 'policy_not_found', ]; $didMutateBackupSet = true; - $bulkOperationService->recordFailure( - run: $run, - itemId: (string) $policyId, - reason: 'Policy not found.', - reasonCode: 'policy_not_found', - ); + $operationRunService->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runFailuresForOperationRun[] = [ + 'code' => 'policy.not_found', + 'message' => "Policy {$policyId} not found.", + ]; continue; } if ($policy->ignored_at) { - $bulkOperationService->recordSkippedWithReason( - run: $run, - itemId: (string) $policyId, - reason: 'Policy is ignored locally', - reasonCode: 'policy_ignored', - ); + $operationRunService->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); continue; } @@ -234,16 +250,16 @@ public function handle( $captureResult = $captureOrchestrator->capture( policy: $policy, tenant: $tenant, - includeAssignments: $this->includeAssignments, - includeScopeTags: $this->includeScopeTags, - createdBy: $run->user?->email ? Str::limit($run->user->email, 255, '') : null, + includeAssignments: $includeAssignments, + includeScopeTags: $includeScopeTags, + createdBy: $initiator?->email ? Str::limit((string) $initiator->email, 255, '') : null, metadata: [ 'source' => 'backup', 'backup_set_id' => $backupSet->getKey(), ], ); } catch (Throwable $throwable) { - $reason = $bulkOperationService->sanitizeFailureReason($throwable->getMessage()); + $reason = RunFailureSanitizer::sanitizeMessage($throwable->getMessage()); $newBackupFailures[] = [ 'policy_id' => $policyId, @@ -253,12 +269,15 @@ public function handle( ]; $didMutateBackupSet = true; - $bulkOperationService->recordFailure( - run: $run, - itemId: (string) $policyId, - reason: $reason, - reasonCode: 'unknown', - ); + $operationRunService->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runFailuresForOperationRun[] = [ + 'code' => 'policy.capture_exception', + 'message' => $reason, + ]; continue; } @@ -267,7 +286,7 @@ public function handle( $failure = $captureResult['failure']; $status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null; $reasonCode = $this->mapGraphFailureReasonCode($status); - $reason = $bulkOperationService->sanitizeFailureReason((string) ($failure['reason'] ?? 'Graph capture failed.')); + $reason = RunFailureSanitizer::sanitizeMessage((string) ($failure['reason'] ?? 'Graph capture failed.')); $newBackupFailures[] = [ 'policy_id' => $policyId, @@ -277,12 +296,15 @@ public function handle( ]; $didMutateBackupSet = true; - $bulkOperationService->recordFailure( - run: $run, - itemId: (string) $policyId, - reason: $reason, - reasonCode: $reasonCode, - ); + $operationRunService->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runFailuresForOperationRun[] = [ + 'code' => "graph.{$reasonCode}", + 'message' => $reason, + ]; continue; } @@ -293,18 +315,21 @@ public function handle( if (! $version || ! is_array($captured)) { $newBackupFailures[] = [ 'policy_id' => $policyId, - 'reason' => $bulkOperationService->sanitizeFailureReason('Capture result missing version payload.'), + 'reason' => RunFailureSanitizer::sanitizeMessage('Capture result missing version payload.'), 'status' => null, 'reason_code' => 'unknown', ]; $didMutateBackupSet = true; - $bulkOperationService->recordFailure( - run: $run, - itemId: (string) $policyId, - reason: 'Capture result missing version payload.', - reasonCode: 'unknown', - ); + $operationRunService->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runFailuresForOperationRun[] = [ + 'code' => 'capture.missing_payload', + 'message' => 'Capture result missing version payload.', + ]; continue; } @@ -352,12 +377,10 @@ public function handle( ]); } catch (QueryException $exception) { if ((string) $exception->getCode() === '23505') { - $bulkOperationService->recordSkippedWithReason( - run: $run, - itemId: (string) $policyId, - reason: 'Already in backup set', - reasonCode: 'already_in_backup_set', - ); + $operationRunService->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); continue; } @@ -369,12 +392,15 @@ public function handle( $didMutateBackupSet = true; $backupSetItemMutations++; - $bulkOperationService->recordSuccess($run); + $operationRunService->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'succeeded' => 1, + 'created' => 1, + ]); } - if ($this->includeFoundations) { + if ($includeFoundations) { [$foundationOutcome, $foundationFailureEntries] = $this->captureFoundations( - bulkOperationService: $bulkOperationService, foundationSnapshots: $foundationSnapshots, tenant: $tenant, backupSet: $backupSet, @@ -391,13 +417,10 @@ public function handle( $newBackupFailures = array_merge($newBackupFailures, $foundationFailureEntries); foreach ($foundationFailureEntries as $foundationFailure) { - $this->appendRunFailure($run, [ - 'type' => 'foundation', - 'item_id' => (string) ($foundationFailure['foundation_type'] ?? 'foundation'), - 'reason_code' => (string) ($foundationFailure['reason_code'] ?? 'unknown'), - 'reason' => (string) ($foundationFailure['reason'] ?? 'Foundation capture failed.'), - 'status' => $foundationFailure['status'] ?? null, - ]); + $runFailuresForOperationRun[] = [ + 'code' => 'foundation.capture_failed', + 'message' => (string) ($foundationFailure['reason'] ?? 'Foundation capture failed.'), + ]; } } } @@ -420,45 +443,41 @@ public function handle( ]); } - $bulkOperationService->complete($run); + $this->operationRun->refresh(); - if ($this->operationRun) { - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); + $counts = is_array($this->operationRun->summary_counts) ? $this->operationRun->summary_counts : []; + $failed = (int) ($counts['failed'] ?? 0); + $succeeded = (int) ($counts['succeeded'] ?? 0); + $skipped = (int) ($counts['skipped'] ?? 0); - $opOutcome = match (true) { - $run->status === 'completed' => 'succeeded', - $run->status === 'completed_with_errors' => 'partially_succeeded', - $run->status === 'failed' => 'failed', - default => 'failed' - }; - - $opService->updateRun( - $this->operationRun, - 'completed', - $opOutcome, - [ - 'policies_added' => $backupSetItemMutations, - 'foundations_added' => $foundationMutations, - 'failures' => count($newBackupFailures), - ], - $newBackupFailures - ); + $outcome = 'succeeded'; + if ($failed > 0 && $succeeded > 0) { + $outcome = 'partially_succeeded'; + } + if ($failed > 0 && $succeeded === 0) { + $outcome = 'failed'; } - if (! $run->user) { + $operationRunService->updateRun( + $this->operationRun, + status: 'completed', + outcome: $outcome, + failures: $runFailuresForOperationRun, + ); + + if (! $initiator instanceof User) { return; } - $message = "Added {$run->succeeded} policies"; - if ($run->skipped > 0) { - $message .= " ({$run->skipped} skipped)"; + $message = "Added {$succeeded} policies"; + if ($skipped > 0) { + $message .= " ({$skipped} skipped)"; } - if ($run->failed > 0) { - $message .= " ({$run->failed} failed)"; + if ($failed > 0) { + $message .= " ({$failed} failed)"; } - if ($this->includeFoundations) { + if ($includeFoundations) { $message .= ". Foundations: {$foundationMutations} items"; if ($foundationFailures > 0) { @@ -468,7 +487,7 @@ public function handle( $message .= '.'; - $partial = $run->status === 'completed_with_errors' || $foundationFailures > 0; + $partial = $outcome === 'partially_succeeded' || $foundationFailures > 0; $notification = Notification::make() ->title($partial ? 'Add Policies Completed (partial)' : 'Add Policies Completed') @@ -476,7 +495,7 @@ public function handle( ->actions([ \Filament\Actions\Action::make('view_run') ->label('View run') - ->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $tenant) : BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), + ->url(OperationRunLinks::view($this->operationRun, $tenant)), ]); if ($partial) { @@ -486,22 +505,15 @@ public function handle( } $notification - ->sendToDatabase($run->user) + ->sendToDatabase($initiator) ->send(); } catch (Throwable $throwable) { - $run->refresh(); - - if (in_array($run->status, ['completed', 'completed_with_errors'], true)) { - throw $throwable; - } - - $this->markRunFailed( - bulkOperationService: $bulkOperationService, - run: $run, + $this->failRun( + operationRunService: $operationRunService, tenant: $tenant instanceof Tenant ? $tenant : null, - itemId: (string) $this->backupSetId, - reasonCode: 'unknown', - reason: $throwable->getMessage(), + code: 'exception.unhandled', + message: $throwable->getMessage(), + initiator: $initiator, ); // TrackOperationRun will catch this throw @@ -510,20 +522,11 @@ public function handle( } /** + * @param array $policyIds * @return array */ - private function extractPolicyIds(BulkOperationRun $run): array + private function normalizePolicyIds(array $policyIds): array { - $itemIds = $run->item_ids ?? []; - - $policyIds = []; - - if (is_array($itemIds) && array_key_exists('policy_ids', $itemIds) && is_array($itemIds['policy_ids'])) { - $policyIds = $itemIds['policy_ids']; - } elseif (is_array($itemIds)) { - $policyIds = $itemIds; - } - $policyIds = array_values(array_unique(array_map('intval', $policyIds))); $policyIds = array_values(array_filter($policyIds, fn (int $value): bool => $value > 0)); sort($policyIds); @@ -531,61 +534,32 @@ private function extractPolicyIds(BulkOperationRun $run): array return $policyIds; } - /** - * @param array $entry - */ - private function appendRunFailure(BulkOperationRun $run, array $entry): void - { - $failures = $run->failures ?? []; - - $failures[] = array_merge([ - 'timestamp' => now()->toIso8601String(), - ], $entry); - - $run->update(['failures' => $failures]); - } - - private function markRunFailed( - BulkOperationService $bulkOperationService, - BulkOperationRun $run, + private function failRun( + OperationRunService $operationRunService, ?Tenant $tenant, - string $itemId, - string $reasonCode, - string $reason, + string $code, + string $message, + ?User $initiator = null, ): void { - $reason = $bulkOperationService->sanitizeFailureReason($reason); + $safeMessage = RunFailureSanitizer::sanitizeMessage($message); + $safeCode = RunFailureSanitizer::sanitizeCode($code); - $this->appendRunFailure($run, [ - 'type' => 'run', - 'item_id' => $itemId, - 'reason_code' => $reasonCode, - 'reason' => $reason, - ]); + $operationRunService->updateRun( + $this->operationRun, + status: 'completed', + outcome: 'failed', + failures: [[ + 'code' => $safeCode, + 'message' => $safeMessage, + ]], + ); - try { - $bulkOperationService->fail($run, $reason); - } catch (Throwable) { - $run->update(['status' => 'failed']); - } - - if ($this->operationRun) { - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opService->updateRun( - $this->operationRun, - 'completed', - 'failed', - ['failure_reason' => $reason], - [['code' => $reasonCode, 'message' => $reason]] - ); - } - - $this->notifyRunFailed($run, $tenant, $reason); + $this->notifyRunFailed($initiator, $tenant, $safeMessage); } - private function notifyRunFailed(BulkOperationRun $run, ?Tenant $tenant, string $reason): void + private function notifyRunFailed(?User $initiator, ?Tenant $tenant, string $reason): void { - if (! $run->user) { + if (! $initiator instanceof User) { return; } @@ -597,13 +571,13 @@ private function notifyRunFailed(BulkOperationRun $run, ?Tenant $tenant, string $notification->actions([ \Filament\Actions\Action::make('view_run') ->label('View run') - ->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $tenant) : BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), + ->url(OperationRunLinks::view($this->operationRun, $tenant)), ]); } $notification ->danger() - ->sendToDatabase($run->user) + ->sendToDatabase($initiator) ->send(); } @@ -621,7 +595,6 @@ private function mapGraphFailureReasonCode(?int $status): string * @return array{0:array{created:int,restored:int,failures:array},1:array} */ private function captureFoundations( - BulkOperationService $bulkOperationService, FoundationSnapshotService $foundationSnapshots, Tenant $tenant, BackupSet $backupSet, @@ -647,7 +620,7 @@ private function captureFoundations( $status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null; $reasonCode = $this->mapGraphFailureReasonCode($status); - $reason = $bulkOperationService->sanitizeFailureReason((string) ($failure['reason'] ?? 'Foundation capture failed.')); + $reason = RunFailureSanitizer::sanitizeMessage((string) ($failure['reason'] ?? 'Foundation capture failed.')); $failures[] = [ 'foundation_type' => $foundationType, diff --git a/app/Jobs/BulkBackupSetDeleteJob.php b/app/Jobs/BulkBackupSetDeleteJob.php index fce556e..9351ea0 100644 --- a/app/Jobs/BulkBackupSetDeleteJob.php +++ b/app/Jobs/BulkBackupSetDeleteJob.php @@ -2,149 +2,93 @@ namespace App\Jobs; -use App\Models\BackupSet; -use App\Models\BulkOperationRun; -use App\Services\BulkOperationService; -use Filament\Notifications\Notification; +use App\Jobs\Operations\BackupSetDeleteWorkerJob; +use App\Models\OperationRun; +use App\Services\OperationRunService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Throwable; +use RuntimeException; class BulkBackupSetDeleteJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public ?OperationRun $operationRun = null; + + /** + * @param array $backupSetIds + * @param array $context + */ public function __construct( - public int $bulkRunId, - ) {} + public int $tenantId, + public int $userId, + public array $backupSetIds, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } - public function handle(BulkOperationService $service): void + public function handle(OperationRunService $runs): void { - $run = BulkOperationRun::with('user')->find($this->bulkRunId); + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for BulkBackupSetDeleteJob.'); + } - if (! $run || $run->status !== 'pending') { + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { return; } - $service->start($run); + $runs->updateRun($this->operationRun, 'running'); - $itemCount = 0; - $succeeded = 0; - $failed = 0; - $skipped = 0; - $skipReasons = []; + $ids = $this->normalizeIds($this->backupSetIds); - $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); - $totalItems = $run->total_items ?: count($run->item_ids ?? []); - $failureThreshold = (int) floor($totalItems / 2); + $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]); - foreach (($run->item_ids ?? []) as $backupSetId) { - $itemCount++; + $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10); + $chunkSize = max(1, $chunkSize); - try { - /** @var BackupSet|null $backupSet */ - $backupSet = BackupSet::withTrashed() - ->where('tenant_id', $run->tenant_id) - ->whereKey($backupSetId) - ->first(); + foreach (array_chunk($ids, $chunkSize) as $chunk) { + foreach ($chunk as $backupSetId) { + dispatch(new BackupSetDeleteWorkerJob( + tenantId: $this->tenantId, + userId: $this->userId, + backupSetId: $backupSetId, + operationRun: $this->operationRun, + context: $this->context, + )); + } + } + } - if (! $backupSet) { - $service->recordFailure($run, (string) $backupSetId, 'Backup set not found'); - $failed++; + /** + * @param array $ids + * @return array + */ + private function normalizeIds(array $ids): array + { + $normalized = []; - if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + foreach ($ids as $id) { + if (is_int($id)) { + $normalized[] = $id; - 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; - } - - $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; - } + continue; } - if ($itemCount % $chunkSize === 0) { - $run->refresh(); + if (is_numeric($id)) { + $normalized[] = (int) $id; } } - $service->complete($run); + $normalized = array_values(array_unique($normalized)); + sort($normalized); - 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(); + return $normalized; } } diff --git a/app/Jobs/BulkBackupSetForceDeleteJob.php b/app/Jobs/BulkBackupSetForceDeleteJob.php index b53a1e4..a07b11a 100644 --- a/app/Jobs/BulkBackupSetForceDeleteJob.php +++ b/app/Jobs/BulkBackupSetForceDeleteJob.php @@ -2,159 +2,93 @@ namespace App\Jobs; -use App\Models\BackupSet; -use App\Models\BulkOperationRun; -use App\Services\BulkOperationService; -use Filament\Notifications\Notification; +use App\Jobs\Operations\BackupSetForceDeleteWorkerJob; +use App\Models\OperationRun; +use App\Services\OperationRunService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Throwable; +use RuntimeException; class BulkBackupSetForceDeleteJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public ?OperationRun $operationRun = null; + + /** + * @param array $backupSetIds + * @param array $context + */ public function __construct( - public int $bulkRunId, - ) {} + public int $tenantId, + public int $userId, + public array $backupSetIds, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } - public function handle(BulkOperationService $service): void + public function handle(OperationRunService $runs): void { - $run = BulkOperationRun::with('user')->find($this->bulkRunId); + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for BulkBackupSetForceDeleteJob.'); + } - if (! $run || $run->status !== 'pending') { + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { return; } - $service->start($run); + $runs->updateRun($this->operationRun, 'running'); - $itemCount = 0; - $succeeded = 0; - $failed = 0; - $skipped = 0; - $skipReasons = []; + $ids = $this->normalizeIds($this->backupSetIds); - $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); - $totalItems = $run->total_items ?: count($run->item_ids ?? []); - $failureThreshold = (int) floor($totalItems / 2); + $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]); - foreach (($run->item_ids ?? []) as $backupSetId) { - $itemCount++; + $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10); + $chunkSize = max(1, $chunkSize); - try { - /** @var BackupSet|null $backupSet */ - $backupSet = BackupSet::withTrashed() - ->where('tenant_id', $run->tenant_id) - ->whereKey($backupSetId) - ->first(); + foreach (array_chunk($ids, $chunkSize) as $chunk) { + foreach ($chunk as $backupSetId) { + dispatch(new BackupSetForceDeleteWorkerJob( + tenantId: $this->tenantId, + userId: $this->userId, + backupSetId: $backupSetId, + operationRun: $this->operationRun, + context: $this->context, + )); + } + } + } - if (! $backupSet) { - $service->recordFailure($run, (string) $backupSetId, 'Backup set not found'); - $failed++; + /** + * @param array $ids + * @return array + */ + private function normalizeIds(array $ids): array + { + $normalized = []; - if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + foreach ($ids as $id) { + if (is_int($id)) { + $normalized[] = $id; - if ($run->user) { - Notification::make() - ->title('Bulk Force Delete 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, 'Not archived'); - $skipped++; - $skipReasons['Not archived'] = ($skipReasons['Not 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->items()->withTrashed()->forceDelete(); - $backupSet->forceDelete(); - - $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 Force Delete Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - return; - } + continue; } - if ($itemCount % $chunkSize === 0) { - $run->refresh(); + if (is_numeric($id)) { + $normalized[] = (int) $id; } } - $service->complete($run); + $normalized = array_values(array_unique($normalized)); + sort($normalized); - if (! $run->user) { - return; - } - - $message = "Force deleted {$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 Force Delete Completed') - ->body($message) - ->icon('heroicon-o-check-circle') - ->success() - ->sendToDatabase($run->user) - ->send(); + return $normalized; } } diff --git a/app/Jobs/BulkBackupSetRestoreJob.php b/app/Jobs/BulkBackupSetRestoreJob.php index 0d39cea..ceb34c2 100644 --- a/app/Jobs/BulkBackupSetRestoreJob.php +++ b/app/Jobs/BulkBackupSetRestoreJob.php @@ -2,151 +2,138 @@ namespace App\Jobs; -use App\Models\BackupSet; -use App\Models\BulkOperationRun; -use App\Services\BulkOperationService; -use Filament\Notifications\Notification; +use App\Jobs\Operations\BackupSetRestoreWorkerJob; +use App\Models\OperationRun; +use App\Services\OperationRunService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use RuntimeException; use Throwable; class BulkBackupSetRestoreJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public int $bulkRunId = 0; + + public ?OperationRun $operationRun = null; + + /** + * @param array $backupSetIds + * @param array $context + */ public function __construct( - public int $bulkRunId, - ) {} + public int $tenantId, + public int $userId, + public array $backupSetIds, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + $this->bulkRunId = $operationRun?->getKey() ? (int) $operationRun->getKey() : 0; + } - public function handle(BulkOperationService $service): void + public function handle(OperationRunService $runs): void { - $run = BulkOperationRun::with('user')->find($this->bulkRunId); + $this->operationRun = $this->resolveOperationRun(); - if (! $run || $run->status !== 'pending') { + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { return; } - $service->start($run); + $runs->updateRun($this->operationRun, 'running'); - $itemCount = 0; - $succeeded = 0; - $failed = 0; - $skipped = 0; - $skipReasons = []; + $ids = $this->normalizeIds($this->backupSetIds); - $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); - $totalItems = $run->total_items ?: count($run->item_ids ?? []); - $failureThreshold = (int) floor($totalItems / 2); + $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]); - foreach (($run->item_ids ?? []) as $backupSetId) { - $itemCount++; + $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10); + $chunkSize = max(1, $chunkSize); - 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 Restore 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, 'Not archived'); - $skipped++; - $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; - - continue; - } - - $backupSet->restore(); - $backupSet->items()->withTrashed()->restore(); - - $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 Restore 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(); + foreach (array_chunk($ids, $chunkSize) as $chunk) { + foreach ($chunk as $backupSetId) { + dispatch(new BackupSetRestoreWorkerJob( + tenantId: $this->tenantId, + userId: $this->userId, + backupSetId: $backupSetId, + operationRun: $this->operationRun, + context: $this->context, + )); } } + } - $service->complete($run); + public function failed(Throwable $e): void + { + $run = $this->operationRun; - if (! $run->user) { + if (! $run instanceof OperationRun && $this->bulkRunId > 0) { + $run = OperationRun::query()->find($this->bulkRunId); + } + + if (! $run instanceof OperationRun) { return; } - $message = "Restored {$succeeded} backup sets"; - if ($skipped > 0) { - $message .= " ({$skipped} skipped)"; - } - if ($failed > 0) { - $message .= " ({$failed} failed)"; + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $runs->updateRun( + $run, + status: 'completed', + outcome: 'failed', + failures: [[ + 'code' => 'bulk_job.failed', + 'message' => $e->getMessage(), + ]], + ); + } + + private function resolveOperationRun(): OperationRun + { + if ($this->operationRun instanceof OperationRun) { + return $this->operationRun; } - if (! empty($skipReasons)) { - $summary = collect($skipReasons) - ->sortDesc() - ->map(fn (int $count, string $reason) => "{$reason} ({$count})") - ->take(3) - ->implode(', '); + if ($this->bulkRunId > 0) { + $run = OperationRun::query()->find($this->bulkRunId); - if ($summary !== '') { - $message .= " Skip reasons: {$summary}."; + if ($run instanceof OperationRun) { + return $run; } } - $message .= '.'; + throw new RuntimeException('OperationRun is required for BulkBackupSetRestoreJob.'); + } - Notification::make() - ->title('Bulk Restore Completed') - ->body($message) - ->icon('heroicon-o-check-circle') - ->success() - ->sendToDatabase($run->user) - ->send(); + /** + * @param array $ids + * @return array + */ + private function normalizeIds(array $ids): array + { + $normalized = []; + + foreach ($ids as $id) { + if (is_int($id)) { + $normalized[] = $id; + + continue; + } + + if (is_numeric($id)) { + $normalized[] = (int) $id; + } + } + + $normalized = array_values(array_unique($normalized)); + sort($normalized); + + return $normalized; } } diff --git a/app/Jobs/BulkPolicyDeleteJob.php b/app/Jobs/BulkPolicyDeleteJob.php index 20fb6b1..a48b2fd 100644 --- a/app/Jobs/BulkPolicyDeleteJob.php +++ b/app/Jobs/BulkPolicyDeleteJob.php @@ -2,184 +2,93 @@ namespace App\Jobs; -use App\Models\BulkOperationRun; -use App\Models\Policy; -use App\Services\BulkOperationService; -use Filament\Notifications\Notification; +use App\Jobs\Operations\PolicyBulkDeleteWorkerJob; +use App\Models\OperationRun; +use App\Services\OperationRunService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Throwable; +use RuntimeException; class BulkPolicyDeleteJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public ?OperationRun $operationRun = null; + + /** + * @param array $policyIds + * @param array $context + */ public function __construct( + public int $tenantId, + public int $userId, + public array $policyIds, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } - public int $bulkRunId - - ) {} - - public function handle(BulkOperationService $service): void + public function handle(OperationRunService $runs): void { + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for BulkPolicyDeleteJob.'); + } - $run = BulkOperationRun::with('user')->find($this->bulkRunId); - - if (! $run || $run->status !== 'pending') { + $this->operationRun->refresh(); + if ($this->operationRun->status === 'completed') { return; - } - $service->start($run); + $runs->updateRun($this->operationRun, 'running'); - try { + $ids = $this->normalizeIds($this->policyIds); - $itemCount = 0; - $succeeded = 0; - $failed = 0; - $skipped = 0; - $failures = []; + $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]); - $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); + $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10); + $chunkSize = max(1, $chunkSize); - $totalItems = $run->total_items ?: count($run->item_ids ?? []); - $failureThreshold = (int) floor($totalItems / 2); + foreach (array_chunk($ids, $chunkSize) as $chunk) { + foreach ($chunk as $policyId) { + dispatch(new PolicyBulkDeleteWorkerJob( + tenantId: $this->tenantId, + userId: $this->userId, + policyId: $policyId, + operationRun: $this->operationRun, + context: $this->context, + )); + } + } + } - foreach ($run->item_ids as $policyId) { + /** + * @param array $ids + * @return array + */ + private function normalizeIds(array $ids): array + { + $normalized = []; - $itemCount++; - - try { - - $policy = Policy::find($policyId); - - if (! $policy) { - - $service->recordFailure($run, (string) $policyId, 'Policy not found'); - $failed++; - $failures[] = [ - 'item_id' => (string) $policyId, - 'reason' => 'Policy not found', - 'timestamp' => now()->toIso8601String(), - ]; - - if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - - if ($run->user) { - Notification::make() - ->title('Bulk Delete Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - return; - } - - continue; - - } - - if ($policy->ignored_at) { - - $service->recordSkipped($run); - $skipped++; - - continue; - - } - - $policy->ignore(); - - $service->recordSuccess($run); - $succeeded++; - - } catch (Throwable $e) { - - $service->recordFailure($run, (string) $policyId, $e->getMessage()); - $failed++; - $failures[] = [ - 'item_id' => (string) $policyId, - 'reason' => $e->getMessage(), - 'timestamp' => now()->toIso8601String(), - ]; - - if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - - if ($run->user) { - Notification::make() - ->title('Bulk Delete Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - return; - } - - } - - // Refresh the run from database every $chunkSize items to avoid stale data - - if ($itemCount % $chunkSize === 0) { - - $run->refresh(); - - } + foreach ($ids as $id) { + if (is_int($id)) { + $normalized[] = $id; + continue; } - $service->complete($run); - - if ($succeeded > 0 || $failed > 0 || $skipped > 0) { - $message = "Successfully deleted {$succeeded} policies"; - if ($skipped > 0) { - $message .= " ({$skipped} skipped)"; - } - if ($failed > 0) { - $message .= " ({$failed} failed)"; - } - $message .= '.'; - - Notification::make() - ->title('Bulk Delete Completed') - ->body($message) - ->icon('heroicon-o-check-circle') - ->success() - ->sendToDatabase($run->user) - ->send(); + if (is_numeric($id)) { + $normalized[] = (int) $id; } - - } catch (Throwable $e) { - - $service->fail($run, $e->getMessage()); - - // Reload run with user relationship - $run->refresh(); - $run->load('user'); - - if ($run->user) { - Notification::make() - ->title('Bulk Delete Failed') - ->body($e->getMessage()) - ->icon('heroicon-o-x-circle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - throw $e; } + $normalized = array_values(array_unique($normalized)); + sort($normalized); + + return $normalized; } } diff --git a/app/Jobs/BulkPolicyExportJob.php b/app/Jobs/BulkPolicyExportJob.php index 76b51c3..d04a39a 100644 --- a/app/Jobs/BulkPolicyExportJob.php +++ b/app/Jobs/BulkPolicyExportJob.php @@ -2,11 +2,17 @@ namespace App\Jobs; +use App\Jobs\Middleware\TrackOperationRun; use App\Models\BackupItem; use App\Models\BackupSet; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\Policy; -use App\Services\BulkOperationService; +use App\Models\Tenant; +use App\Models\User; +use App\Services\OperationRunService; +use App\Support\OperationRunLinks; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -19,31 +25,53 @@ class BulkPolicyExportJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public ?OperationRun $operationRun = null; + public function __construct( - public int $bulkRunId, + public int $tenantId, + public int $userId, + /** @var array */ + public array $policyIds, public string $backupName, - public ?string $backupDescription = null - ) {} + public ?string $backupDescription = null, + ?OperationRun $operationRun = null, + ) { + $this->operationRun = $operationRun; + } - public function handle(BulkOperationService $service): void + public function middleware(): array { - $run = BulkOperationRun::with('user')->find($this->bulkRunId); + return [new TrackOperationRun]; + } - if (! $run || $run->status !== 'pending') { - return; + public function handle(OperationRunService $operationRunService): void + { + $tenant = Tenant::query()->find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new \RuntimeException('Tenant not found.'); } - $service->start($run); + $user = User::query()->find($this->userId); + if (! $user instanceof User) { + throw new \RuntimeException('User not found.'); + } + + $ids = collect($this->policyIds) + ->map(static fn ($id): int => (int) $id) + ->unique() + ->sort() + ->values() + ->all(); try { // Create Backup Set $backupSet = BackupSet::create([ - 'tenant_id' => $run->tenant_id, + 'tenant_id' => $tenant->getKey(), 'name' => $this->backupName, // 'description' => $this->backupDescription, // Not in schema 'status' => 'completed', - 'created_by' => $run->user?->name ?? (string) $run->user_id, // Schema has created_by string - 'item_count' => count($run->item_ids), + 'created_by' => $user->name, + 'item_count' => count($ids), 'completed_at' => now(), ]); @@ -51,37 +79,55 @@ public function handle(BulkOperationService $service): void $succeeded = 0; $failed = 0; $failures = []; - $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); - $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $totalItems = count($ids); $failureThreshold = (int) floor($totalItems / 2); - foreach ($run->item_ids as $policyId) { + foreach ($ids as $policyId) { $itemCount++; try { - $policy = Policy::find($policyId); + $policy = Policy::query() + ->where('tenant_id', $tenant->getKey()) + ->find($policyId); if (! $policy) { - $service->recordFailure($run, (string) $policyId, 'Policy not found'); $failed++; - $failures[] = [ - 'item_id' => (string) $policyId, - 'reason' => 'Policy not found', - 'timestamp' => now()->toIso8601String(), - ]; + $failures[] = ['code' => 'policy.not_found', 'message' => "Policy {$policyId} not found."]; if ($failed > $failureThreshold) { $backupSet->update(['status' => 'failed']); - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - if ($run->user) { + if ($this->operationRun) { + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + summaryCounts: [ + 'total' => $totalItems, + 'processed' => $itemCount, + 'succeeded' => $succeeded, + 'failed' => $failed, + 'created' => $succeeded, + ], + failures: array_merge($failures, [ + ['code' => 'export.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'], + ]), + ); + } + + if ($user) { Notification::make() ->title('Bulk Export Aborted') ->body('Circuit breaker triggered: too many failures (>50%).') ->icon('heroicon-o-exclamation-triangle') ->danger() - ->sendToDatabase($run->user) + ->actions($this->operationRun ? [ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ] : []) + ->sendToDatabase($user) ->send(); } @@ -95,25 +141,42 @@ public function handle(BulkOperationService $service): void $latestVersion = $policy->versions()->orderByDesc('captured_at')->first(); if (! $latestVersion) { - $service->recordFailure($run, (string) $policyId, 'No versions available for policy'); $failed++; - $failures[] = [ - 'item_id' => (string) $policyId, - 'reason' => 'No versions available for policy', - 'timestamp' => now()->toIso8601String(), - ]; + $failures[] = ['code' => 'policy.no_versions', 'message' => "No versions available for policy {$policyId}."]; if ($failed > $failureThreshold) { $backupSet->update(['status' => 'failed']); - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - if ($run->user) { + if ($this->operationRun) { + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + summaryCounts: [ + 'total' => $totalItems, + 'processed' => $itemCount, + 'succeeded' => $succeeded, + 'failed' => $failed, + 'created' => $succeeded, + ], + failures: array_merge($failures, [ + ['code' => 'export.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'], + ]), + ); + } + + if ($user) { Notification::make() ->title('Bulk Export Aborted') ->body('Circuit breaker triggered: too many failures (>50%).') ->icon('heroicon-o-exclamation-triangle') ->danger() - ->sendToDatabase($run->user) + ->actions($this->operationRun ? [ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ] : []) + ->sendToDatabase($user) ->send(); } @@ -125,7 +188,7 @@ public function handle(BulkOperationService $service): void // Create Backup Item BackupItem::create([ - 'tenant_id' => $run->tenant_id, + 'tenant_id' => $tenant->getKey(), 'backup_set_id' => $backupSet->id, 'policy_id' => $policy->id, 'policy_identifier' => $policy->external_id, // Added @@ -139,46 +202,81 @@ public function handle(BulkOperationService $service): void ], ]); - $service->recordSuccess($run); $succeeded++; } catch (Throwable $e) { - $service->recordFailure($run, (string) $policyId, $e->getMessage()); $failed++; - $failures[] = [ - 'item_id' => (string) $policyId, - 'reason' => $e->getMessage(), - 'timestamp' => now()->toIso8601String(), - ]; + $failures[] = ['code' => 'policy.export.failed', 'message' => $e->getMessage()]; if ($failed > $failureThreshold) { $backupSet->update(['status' => 'failed']); - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - if ($run->user) { + if ($this->operationRun) { + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + summaryCounts: [ + 'total' => $totalItems, + 'processed' => $itemCount, + 'succeeded' => $succeeded, + 'failed' => $failed, + 'created' => $succeeded, + ], + failures: array_merge($failures, [ + ['code' => 'export.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'], + ]), + ); + } + + if ($user) { Notification::make() ->title('Bulk Export Aborted') ->body('Circuit breaker triggered: too many failures (>50%).') ->icon('heroicon-o-exclamation-triangle') ->danger() - ->sendToDatabase($run->user) + ->actions($this->operationRun ? [ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ] : []) + ->sendToDatabase($user) ->send(); } return; } } - - // Refresh the run from database every 10 items to avoid stale data - if ($itemCount % $chunkSize === 0) { - $run->refresh(); - } } // Update BackupSet item count (if denormalized) or just leave it // Assuming BackupSet might need an item count or status update - $service->complete($run); + $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, + 'created' => $succeeded, + ], + failures: $failures, + ); + } if ($succeeded > 0 || $failed > 0) { $message = "Successfully exported {$succeeded} policies to backup '{$this->backupName}'"; @@ -192,24 +290,39 @@ public function handle(BulkOperationService $service): void ->body($message) ->icon('heroicon-o-check-circle') ->success() - ->sendToDatabase($run->user) + ->actions($this->operationRun ? [ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ] : []) + ->sendToDatabase($user) ->send(); } } catch (Throwable $e) { - $service->fail($run, $e->getMessage()); + if ($this->operationRun) { + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + failures: [ + ['code' => 'exception.unhandled', 'message' => $e->getMessage()], + ], + ); + } - // Reload run with user relationship - $run->refresh(); - $run->load('user'); - - if ($run->user) { + if (isset($user) && $user instanceof User) { Notification::make() ->title('Bulk Export Failed') ->body($e->getMessage()) ->icon('heroicon-o-x-circle') ->danger() - ->sendToDatabase($run->user) + ->actions($this->operationRun ? [ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ] : []) + ->sendToDatabase($user) ->send(); } diff --git a/app/Jobs/BulkPolicySyncJob.php b/app/Jobs/BulkPolicySyncJob.php index 0ec2f4d..ccefc56 100644 --- a/app/Jobs/BulkPolicySyncJob.php +++ b/app/Jobs/BulkPolicySyncJob.php @@ -2,17 +2,14 @@ namespace App\Jobs; -use App\Models\BulkOperationRun; -use App\Models\Policy; -use App\Services\BulkOperationService; +use App\Models\OperationRun; use App\Services\Intune\PolicySyncService; -use Filament\Notifications\Notification; +use App\Services\OperationRunService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Throwable; class BulkPolicySyncJob implements ShouldQueue { @@ -20,128 +17,24 @@ class BulkPolicySyncJob implements ShouldQueue public function __construct(public int $bulkRunId) {} - public function handle(BulkOperationService $service, PolicySyncService $syncService): void + public function handle(PolicySyncService $syncService, OperationRunService $runs): void { - $run = BulkOperationRun::with(['tenant', 'user'])->find($this->bulkRunId); + $run = OperationRun::query()->find($this->bulkRunId); - if (! $run || $run->status !== 'pending') { + if (! $run instanceof OperationRun) { + return; + } + $policyIds = data_get($run->context ?? [], 'policy_ids'); + + if (! is_array($policyIds)) { return; } - $service->start($run); - - try { - $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); - $itemCount = 0; - - $totalItems = $run->total_items ?: count($run->item_ids ?? []); - $failureThreshold = (int) floor($totalItems / 2); - - foreach (($run->item_ids ?? []) as $policyId) { - $itemCount++; - - try { - $policy = Policy::query() - ->whereKey($policyId) - ->where('tenant_id', $run->tenant_id) - ->first(); - - if (! $policy) { - $service->recordFailure($run, (string) $policyId, 'Policy not found'); - - if ($run->failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - - if ($run->user) { - Notification::make() - ->title('Bulk Sync Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - return; - } - - continue; - } - - if ($policy->ignored_at) { - $service->recordSkippedWithReason($run, (string) $policyId, 'Policy is ignored locally'); - - continue; - } - - $syncService->syncPolicy($run->tenant, $policy); - - $service->recordSuccess($run); - } catch (Throwable $e) { - $service->recordFailure($run, (string) $policyId, $e->getMessage()); - - if ($run->failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - - if ($run->user) { - Notification::make() - ->title('Bulk Sync 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) { - $message = "Synced {$run->succeeded} policies"; - - if ($run->skipped > 0) { - $message .= " ({$run->skipped} skipped)"; - } - - if ($run->failed > 0) { - $message .= " ({$run->failed} failed)"; - } - - $message .= '.'; - - Notification::make() - ->title('Bulk Sync Completed') - ->body($message) - ->icon('heroicon-o-check-circle') - ->success() - ->sendToDatabase($run->user) - ->send(); - } - } catch (Throwable $e) { - $service->fail($run, $e->getMessage()); - - $run->refresh(); - $run->load('user'); - - if ($run->user) { - Notification::make() - ->title('Bulk Sync Failed') - ->body($e->getMessage()) - ->icon('heroicon-o-x-circle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - throw $e; - } + (new SyncPoliciesJob( + tenantId: (int) $run->tenant_id, + types: null, + policyIds: $policyIds, + operationRun: $run, + ))->handle($syncService, $runs); } } diff --git a/app/Jobs/BulkPolicyUnignoreJob.php b/app/Jobs/BulkPolicyUnignoreJob.php index edbf4eb..8fc6b06 100644 --- a/app/Jobs/BulkPolicyUnignoreJob.php +++ b/app/Jobs/BulkPolicyUnignoreJob.php @@ -2,9 +2,15 @@ namespace App\Jobs; -use App\Models\BulkOperationRun; +use App\Jobs\Middleware\TrackOperationRun; +use App\Models\OperationRun; use App\Models\Policy; -use App\Services\BulkOperationService; +use App\Models\Tenant; +use App\Models\User; +use App\Services\OperationRunService; +use App\Support\OperationRunLinks; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -17,63 +23,113 @@ class BulkPolicyUnignoreJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - public function __construct(public int $bulkRunId) {} + public ?OperationRun $operationRun = null; - public function handle(BulkOperationService $service): void + /** + * @param array $policyIds + */ + public function __construct( + public int $tenantId, + public int $userId, + public array $policyIds, + ?OperationRun $operationRun = null, + ) { + $this->operationRun = $operationRun; + } + + public function middleware(): array { - $run = BulkOperationRun::with('user')->find($this->bulkRunId); + return [new TrackOperationRun]; + } - if (! $run || $run->status !== 'pending') { - return; + public function handle(OperationRunService $operationRunService): void + { + $tenant = Tenant::query()->find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new \RuntimeException('Tenant not found.'); } - $service->start($run); + $user = User::query()->find($this->userId); + if (! $user instanceof User) { + throw new \RuntimeException('User not found.'); + } try { - $itemCount = 0; $succeeded = 0; $failed = 0; $skipped = 0; + $failures = []; - $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); + $ids = collect($this->policyIds) + ->map(static fn ($id): int => (int) $id) + ->unique() + ->sort() + ->values() + ->all(); - foreach ($run->item_ids as $policyId) { - $itemCount++; + foreach ($ids as $policyId) { try { - $policy = Policy::find($policyId); + $policy = Policy::query() + ->where('tenant_id', $tenant->getKey()) + ->find($policyId); if (! $policy) { - $service->recordFailure($run, (string) $policyId, 'Policy not found'); $failed++; + $failures[] = [ + 'code' => 'policy.not_found', + 'message' => "Policy {$policyId} not found.", + ]; continue; } if (! $policy->ignored_at) { - $service->recordSkipped($run); $skipped++; continue; } $policy->unignore(); - - $service->recordSuccess($run); $succeeded++; } catch (Throwable $e) { - $service->recordFailure($run, (string) $policyId, $e->getMessage()); $failed++; - } - - if ($itemCount % $chunkSize === 0) { - $run->refresh(); + $failures[] = [ + 'code' => 'policy.unignore.failed', + 'message' => $e->getMessage(), + ]; } } - $service->complete($run); + $total = count($ids); - if ($run->user) { + $outcome = OperationRunOutcome::Succeeded->value; + + if ($failed > 0 && $failed < $total) { + $outcome = OperationRunOutcome::PartiallySucceeded->value; + } + + if ($failed >= $total && $total > 0) { + $outcome = OperationRunOutcome::Failed->value; + } + + if ($this->operationRun) { + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: $outcome, + summaryCounts: [ + 'total' => $total, + 'processed' => $total, + 'succeeded' => $succeeded, + 'failed' => $failed, + 'skipped' => $skipped, + ], + failures: $failures, + ); + } + + if ($user) { $message = "Restored {$succeeded} policies"; if ($skipped > 0) { @@ -91,22 +147,38 @@ public function handle(BulkOperationService $service): void ->body($message) ->icon('heroicon-o-check-circle') ->success() - ->sendToDatabase($run->user) + ->actions($this->operationRun ? [ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ] : []) + ->sendToDatabase($user) ->send(); } } catch (Throwable $e) { - $service->fail($run, $e->getMessage()); + if ($this->operationRun) { + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + failures: [ + ['code' => 'exception.unhandled', 'message' => $e->getMessage()], + ], + ); + } - $run->refresh(); - $run->load('user'); - - if ($run->user) { + if (isset($user) && $user instanceof User) { Notification::make() ->title('Bulk Restore Failed') ->body($e->getMessage()) ->icon('heroicon-o-x-circle') ->danger() - ->sendToDatabase($run->user) + ->actions($this->operationRun ? [ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ] : []) + ->sendToDatabase($user) ->send(); } diff --git a/app/Jobs/BulkPolicyVersionForceDeleteJob.php b/app/Jobs/BulkPolicyVersionForceDeleteJob.php index 2275408..64ed798 100644 --- a/app/Jobs/BulkPolicyVersionForceDeleteJob.php +++ b/app/Jobs/BulkPolicyVersionForceDeleteJob.php @@ -2,147 +2,93 @@ namespace App\Jobs; -use App\Models\BulkOperationRun; -use App\Models\PolicyVersion; -use App\Services\BulkOperationService; -use Filament\Notifications\Notification; +use App\Jobs\Operations\PolicyVersionForceDeleteWorkerJob; +use App\Models\OperationRun; +use App\Services\OperationRunService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Throwable; +use RuntimeException; class BulkPolicyVersionForceDeleteJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public ?OperationRun $operationRun = null; + + /** + * @param array $policyVersionIds + * @param array $context + */ public function __construct( - public int $bulkRunId, - ) {} + public int $tenantId, + public int $userId, + public array $policyVersionIds, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } - public function handle(BulkOperationService $service): void + public function handle(OperationRunService $runs): void { - $run = BulkOperationRun::with('user')->find($this->bulkRunId); + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for BulkPolicyVersionForceDeleteJob.'); + } - if (! $run || $run->status !== 'pending') { + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { return; } - $service->start($run); + $runs->updateRun($this->operationRun, 'running'); - $itemCount = 0; - $succeeded = 0; - $failed = 0; - $skipped = 0; - $skipReasons = []; + $ids = $this->normalizeIds($this->policyVersionIds); - $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); - $totalItems = $run->total_items ?: count($run->item_ids ?? []); - $failureThreshold = (int) floor($totalItems / 2); + $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]); - foreach (($run->item_ids ?? []) as $versionId) { - $itemCount++; + $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10); + $chunkSize = max(1, $chunkSize); - try { - /** @var PolicyVersion|null $version */ - $version = PolicyVersion::withTrashed() - ->where('tenant_id', $run->tenant_id) - ->whereKey($versionId) - ->first(); - - if (! $version) { - $service->recordFailure($run, (string) $versionId, 'Policy version not found'); - $failed++; - - if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - - if ($run->user) { - Notification::make() - ->title('Bulk Force Delete Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - return; - } - - continue; - } - - if (! $version->trashed()) { - $service->recordSkippedWithReason($run, (string) $version->id, 'Not archived'); - $skipped++; - $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; - - continue; - } - - $version->forceDelete(); - $service->recordSuccess($run); - $succeeded++; - } catch (Throwable $e) { - $service->recordFailure($run, (string) $versionId, $e->getMessage()); - $failed++; - - if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - - if ($run->user) { - Notification::make() - ->title('Bulk Force Delete Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - return; - } + foreach (array_chunk($ids, $chunkSize) as $chunk) { + foreach ($chunk as $policyVersionId) { + dispatch(new PolicyVersionForceDeleteWorkerJob( + tenantId: $this->tenantId, + userId: $this->userId, + policyVersionId: $policyVersionId, + operationRun: $this->operationRun, + context: $this->context, + )); } - - if ($itemCount % $chunkSize === 0) { - $run->refresh(); - } - } - - $service->complete($run); - - if ($run->user) { - $message = "Force deleted {$succeeded} policy versions"; - 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() - ->sendToDatabase($run->user) - ->send(); } } + + /** + * @param array $ids + * @return array + */ + private function normalizeIds(array $ids): array + { + $normalized = []; + + foreach ($ids as $id) { + if (is_int($id)) { + $normalized[] = $id; + + continue; + } + + if (is_numeric($id)) { + $normalized[] = (int) $id; + } + } + + $normalized = array_values(array_unique($normalized)); + sort($normalized); + + return $normalized; + } } diff --git a/app/Jobs/BulkPolicyVersionPruneJob.php b/app/Jobs/BulkPolicyVersionPruneJob.php index d8009cf..1bc2feb 100644 --- a/app/Jobs/BulkPolicyVersionPruneJob.php +++ b/app/Jobs/BulkPolicyVersionPruneJob.php @@ -2,177 +2,95 @@ namespace App\Jobs; -use App\Models\BulkOperationRun; -use App\Models\PolicyVersion; -use App\Services\BulkOperationService; -use Filament\Notifications\Notification; +use App\Jobs\Operations\PolicyVersionPruneWorkerJob; +use App\Models\OperationRun; +use App\Services\OperationRunService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Throwable; +use RuntimeException; class BulkPolicyVersionPruneJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public ?OperationRun $operationRun = null; + + /** + * @param array $policyVersionIds + * @param array $context + */ public function __construct( - public int $bulkRunId, + public int $tenantId, + public int $userId, + public array $policyVersionIds, public int $retentionDays = 90, - ) {} + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } - public function handle(BulkOperationService $service): void + public function handle(OperationRunService $runs): void { - $run = BulkOperationRun::with('user')->find($this->bulkRunId); + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for BulkPolicyVersionPruneJob.'); + } - if (! $run || $run->status !== 'pending') { + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { return; } - $service->start($run); + $runs->updateRun($this->operationRun, 'running'); - $itemCount = 0; - $succeeded = 0; - $failed = 0; - $skipped = 0; - $skipReasons = []; + $ids = $this->normalizeIds($this->policyVersionIds); - $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); - $totalItems = $run->total_items ?: count($run->item_ids ?? []); - $failureThreshold = (int) floor($totalItems / 2); + $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]); - foreach (($run->item_ids ?? []) as $versionId) { - $itemCount++; + $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10); + $chunkSize = max(1, $chunkSize); - try { - /** @var PolicyVersion|null $version */ - $version = PolicyVersion::withTrashed() - ->where('tenant_id', $run->tenant_id) - ->whereKey($versionId) - ->first(); - - if (! $version) { - $service->recordFailure($run, (string) $versionId, 'Policy version not found'); - $failed++; - - if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - - if ($run->user) { - Notification::make() - ->title('Bulk Prune Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - return; - } - - continue; - } - - if ($version->trashed()) { - $service->recordSkippedWithReason($run, (string) $version->id, 'Already archived'); - $skipped++; - $skipReasons['Already archived'] = ($skipReasons['Already archived'] ?? 0) + 1; - - continue; - } - - $eligible = PolicyVersion::query() - ->where('tenant_id', $run->tenant_id) - ->whereKey($version->id) - ->pruneEligible($this->retentionDays) - ->exists(); - - if (! $eligible) { - $capturedAt = $version->captured_at; - $isTooRecent = $capturedAt && $capturedAt->gte(now()->subDays($this->retentionDays)); - - $latestVersionNumber = PolicyVersion::query() - ->where('tenant_id', $run->tenant_id) - ->where('policy_id', $version->policy_id) - ->whereNull('deleted_at') - ->max('version_number'); - - $isCurrent = $latestVersionNumber !== null && (int) $version->version_number === (int) $latestVersionNumber; - - $reason = $isCurrent - ? 'Current version' - : ($isTooRecent ? 'Too recent' : 'Not eligible'); - - $service->recordSkippedWithReason($run, (string) $version->id, $reason); - $skipped++; - $skipReasons[$reason] = ($skipReasons[$reason] ?? 0) + 1; - - continue; - } - - $version->delete(); - $service->recordSuccess($run); - $succeeded++; - } catch (Throwable $e) { - $service->recordFailure($run, (string) $versionId, $e->getMessage()); - $failed++; - - if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - - if ($run->user) { - Notification::make() - ->title('Bulk Prune Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - return; - } + foreach (array_chunk($ids, $chunkSize) as $chunk) { + foreach ($chunk as $policyVersionId) { + dispatch(new PolicyVersionPruneWorkerJob( + tenantId: $this->tenantId, + userId: $this->userId, + policyVersionId: $policyVersionId, + retentionDays: $this->retentionDays, + operationRun: $this->operationRun, + context: $this->context, + )); } - - if ($itemCount % $chunkSize === 0) { - $run->refresh(); - } - } - - $service->complete($run); - - if ($run->user) { - $message = "Pruned {$succeeded} policy versions"; - 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 Prune Completed') - ->body($message) - ->icon('heroicon-o-check-circle') - ->success() - ->sendToDatabase($run->user) - ->send(); } } + + /** + * @param array $ids + * @return array + */ + private function normalizeIds(array $ids): array + { + $normalized = []; + + foreach ($ids as $id) { + if (is_int($id)) { + $normalized[] = $id; + + continue; + } + + if (is_numeric($id)) { + $normalized[] = (int) $id; + } + } + + $normalized = array_values(array_unique($normalized)); + sort($normalized); + + return $normalized; + } } diff --git a/app/Jobs/BulkPolicyVersionRestoreJob.php b/app/Jobs/BulkPolicyVersionRestoreJob.php index 6e6c17d..2a632fa 100644 --- a/app/Jobs/BulkPolicyVersionRestoreJob.php +++ b/app/Jobs/BulkPolicyVersionRestoreJob.php @@ -2,147 +2,93 @@ namespace App\Jobs; -use App\Models\BulkOperationRun; -use App\Models\PolicyVersion; -use App\Services\BulkOperationService; -use Filament\Notifications\Notification; +use App\Jobs\Operations\PolicyVersionRestoreWorkerJob; +use App\Models\OperationRun; +use App\Services\OperationRunService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Throwable; +use RuntimeException; class BulkPolicyVersionRestoreJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public ?OperationRun $operationRun = null; + + /** + * @param array $policyVersionIds + * @param array $context + */ public function __construct( - public int $bulkRunId, - ) {} + public int $tenantId, + public int $userId, + public array $policyVersionIds, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } - public function handle(BulkOperationService $service): void + public function handle(OperationRunService $runs): void { - $run = BulkOperationRun::with('user')->find($this->bulkRunId); + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for BulkPolicyVersionRestoreJob.'); + } - if (! $run || $run->status !== 'pending') { + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { return; } - $service->start($run); + $runs->updateRun($this->operationRun, 'running'); - $itemCount = 0; - $succeeded = 0; - $failed = 0; - $skipped = 0; - $skipReasons = []; + $ids = $this->normalizeIds($this->policyVersionIds); - $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); - $totalItems = $run->total_items ?: count($run->item_ids ?? []); - $failureThreshold = (int) floor($totalItems / 2); + $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]); - foreach (($run->item_ids ?? []) as $versionId) { - $itemCount++; + $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10); + $chunkSize = max(1, $chunkSize); - try { - /** @var PolicyVersion|null $version */ - $version = PolicyVersion::withTrashed() - ->where('tenant_id', $run->tenant_id) - ->whereKey($versionId) - ->first(); - - if (! $version) { - $service->recordFailure($run, (string) $versionId, 'Policy version not found'); - $failed++; - - if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - - if ($run->user) { - Notification::make() - ->title('Bulk Restore Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - return; - } - - continue; - } - - if (! $version->trashed()) { - $service->recordSkippedWithReason($run, (string) $version->id, 'Not archived'); - $skipped++; - $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; - - continue; - } - - $version->restore(); - $service->recordSuccess($run); - $succeeded++; - } catch (Throwable $e) { - $service->recordFailure($run, (string) $versionId, $e->getMessage()); - $failed++; - - if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - - if ($run->user) { - Notification::make() - ->title('Bulk Restore Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - return; - } + foreach (array_chunk($ids, $chunkSize) as $chunk) { + foreach ($chunk as $policyVersionId) { + dispatch(new PolicyVersionRestoreWorkerJob( + tenantId: $this->tenantId, + userId: $this->userId, + policyVersionId: $policyVersionId, + operationRun: $this->operationRun, + context: $this->context, + )); } - - if ($itemCount % $chunkSize === 0) { - $run->refresh(); - } - } - - $service->complete($run); - - if ($run->user) { - $message = "Restored {$succeeded} policy versions"; - 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 Restore Completed') - ->body($message) - ->icon('heroicon-o-check-circle') - ->success() - ->sendToDatabase($run->user) - ->send(); } } + + /** + * @param array $ids + * @return array + */ + private function normalizeIds(array $ids): array + { + $normalized = []; + + foreach ($ids as $id) { + if (is_int($id)) { + $normalized[] = $id; + + continue; + } + + if (is_numeric($id)) { + $normalized[] = (int) $id; + } + } + + $normalized = array_values(array_unique($normalized)); + sort($normalized); + + return $normalized; + } } diff --git a/app/Jobs/BulkRestoreRunDeleteJob.php b/app/Jobs/BulkRestoreRunDeleteJob.php index 4864e8e..35c0757 100644 --- a/app/Jobs/BulkRestoreRunDeleteJob.php +++ b/app/Jobs/BulkRestoreRunDeleteJob.php @@ -2,176 +2,93 @@ namespace App\Jobs; -use App\Models\BulkOperationRun; -use App\Models\RestoreRun; -use App\Services\BulkOperationService; -use Filament\Notifications\Notification; +use App\Jobs\Operations\RestoreRunDeleteWorkerJob; +use App\Models\OperationRun; +use App\Services\OperationRunService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Throwable; +use RuntimeException; class BulkRestoreRunDeleteJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public ?OperationRun $operationRun = null; + + /** + * @param array $restoreRunIds + * @param array $context + */ public function __construct( - public int $bulkRunId, - ) {} + public int $tenantId, + public int $userId, + public array $restoreRunIds, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } - public function handle(BulkOperationService $service): void + public function handle(OperationRunService $runs): void { - $run = BulkOperationRun::with('user')->find($this->bulkRunId); + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for BulkRestoreRunDeleteJob.'); + } - if (! $run || $run->status !== 'pending') { + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { return; } - $service->start($run); + $runs->updateRun($this->operationRun, 'running'); - try { - $itemCount = 0; - $succeeded = 0; - $failed = 0; - $skipped = 0; - $skipReasons = []; + $ids = $this->normalizeIds($this->restoreRunIds); - $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); - $totalItems = $run->total_items ?: count($run->item_ids ?? []); - $failureThreshold = (int) floor($totalItems / 2); + $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]); - foreach (($run->item_ids ?? []) as $restoreRunId) { - $itemCount++; + $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10); + $chunkSize = max(1, $chunkSize); - try { - /** @var RestoreRun|null $restoreRun */ - $restoreRun = RestoreRun::withTrashed() - ->where('tenant_id', $run->tenant_id) - ->whereKey($restoreRunId) - ->first(); - - if (! $restoreRun) { - $service->recordFailure($run, (string) $restoreRunId, 'Restore run not found'); - $failed++; - - if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - - if ($run->user) { - Notification::make() - ->title('Bulk Delete Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - return; - } - - continue; - } - - if ($restoreRun->trashed()) { - $service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Already archived'); - $skipped++; - $skipReasons['Already archived'] = ($skipReasons['Already archived'] ?? 0) + 1; - - continue; - } - - if (! $restoreRun->isDeletable()) { - $reason = "Not deletable (status: {$restoreRun->status})"; - - $service->recordSkippedWithReason($run, (string) $restoreRun->id, $reason); - $skipped++; - $skipReasons[$reason] = ($skipReasons[$reason] ?? 0) + 1; - - continue; - } - - $restoreRun->delete(); - $service->recordSuccess($run); - $succeeded++; - } catch (Throwable $e) { - $service->recordFailure($run, (string) $restoreRunId, $e->getMessage()); - $failed++; - - if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - - if ($run->user) { - Notification::make() - ->title('Bulk Delete 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(); - } + foreach (array_chunk($ids, $chunkSize) as $chunk) { + foreach ($chunk as $restoreRunId) { + dispatch(new RestoreRunDeleteWorkerJob( + tenantId: $this->tenantId, + userId: $this->userId, + restoreRunId: $restoreRunId, + operationRun: $this->operationRun, + context: $this->context, + )); } - - $service->complete($run); - - if ($run->user) { - $message = "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 Delete Completed') - ->body($message) - ->icon('heroicon-o-check-circle') - ->success() - ->sendToDatabase($run->user) - ->send(); - } - } catch (Throwable $e) { - $service->fail($run, $e->getMessage()); - - $run->refresh(); - $run->load('user'); - - if ($run->user) { - Notification::make() - ->title('Bulk Delete Failed') - ->body($e->getMessage()) - ->icon('heroicon-o-x-circle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - throw $e; } } + + /** + * @param array $ids + * @return array + */ + private function normalizeIds(array $ids): array + { + $normalized = []; + + foreach ($ids as $id) { + if (is_int($id)) { + $normalized[] = $id; + + continue; + } + + if (is_numeric($id)) { + $normalized[] = (int) $id; + } + } + + $normalized = array_values(array_unique($normalized)); + sort($normalized); + + return $normalized; + } } diff --git a/app/Jobs/BulkRestoreRunForceDeleteJob.php b/app/Jobs/BulkRestoreRunForceDeleteJob.php index 5f8dbc5..dd705ee 100644 --- a/app/Jobs/BulkRestoreRunForceDeleteJob.php +++ b/app/Jobs/BulkRestoreRunForceDeleteJob.php @@ -2,9 +2,15 @@ namespace App\Jobs; -use App\Models\BulkOperationRun; +use App\Jobs\Middleware\TrackOperationRun; +use App\Models\OperationRun; use App\Models\RestoreRun; -use App\Services\BulkOperationService; +use App\Models\Tenant; +use App\Models\User; +use App\Services\OperationRunService; +use App\Support\OperationRunLinks; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -17,19 +23,41 @@ class BulkRestoreRunForceDeleteJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public ?OperationRun $operationRun = null; + public function __construct( - public int $bulkRunId, - ) {} + public int $tenantId, + public int $userId, + /** @var array */ + public array $restoreRunIds, + ?OperationRun $operationRun = null, + ) { + $this->operationRun = $operationRun; + } - public function handle(BulkOperationService $service): void + public function middleware(): array { - $run = BulkOperationRun::with('user')->find($this->bulkRunId); + return [new TrackOperationRun]; + } - if (! $run || $run->status !== 'pending') { - return; + public function handle(OperationRunService $operationRunService): void + { + $tenant = Tenant::query()->find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new \RuntimeException('Tenant not found.'); } - $service->start($run); + $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; @@ -37,34 +65,57 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); - $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failures = []; + + $totalItems = count($ids); $failureThreshold = (int) floor($totalItems / 2); - foreach (($run->item_ids ?? []) as $restoreRunId) { + foreach ($ids as $restoreRunId) { $itemCount++; try { /** @var RestoreRun|null $restoreRun */ $restoreRun = RestoreRun::withTrashed() - ->where('tenant_id', $run->tenant_id) + ->where('tenant_id', $tenant->getKey()) ->whereKey($restoreRunId) ->first(); if (! $restoreRun) { - $service->recordFailure($run, (string) $restoreRunId, 'Restore run not found'); $failed++; + $failures[] = ['code' => 'restore_run.not_found', 'message' => "Restore run {$restoreRunId} not found."]; if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + 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 ($run->user) { + if ($user) { Notification::make() ->title('Bulk Force Delete Aborted') ->body('Circuit breaker triggered: too many failures (>50%).') ->icon('heroicon-o-exclamation-triangle') ->danger() - ->sendToDatabase($run->user) + ->actions($this->operationRun ? [ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ] : []) + ->sendToDatabase($user) ->send(); } @@ -75,7 +126,6 @@ public function handle(BulkOperationService $service): void } if (! $restoreRun->trashed()) { - $service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Not archived'); $skipped++; $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; @@ -83,38 +133,76 @@ public function handle(BulkOperationService $service): void } $restoreRun->forceDelete(); - $service->recordSuccess($run); $succeeded++; } catch (Throwable $e) { - $service->recordFailure($run, (string) $restoreRunId, $e->getMessage()); $failed++; + $failures[] = ['code' => 'restore_run.force_delete.failed', 'message' => $e->getMessage()]; if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + 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 ($run->user) { + if ($user) { Notification::make() ->title('Bulk Force Delete Aborted') ->body('Circuit breaker triggered: too many failures (>50%).') ->icon('heroicon-o-exclamation-triangle') ->danger() - ->sendToDatabase($run->user) + ->actions($this->operationRun ? [ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ] : []) + ->sendToDatabase($user) ->send(); } return; } } - - if ($itemCount % $chunkSize === 0) { - $run->refresh(); - } } - $service->complete($run); + $outcome = OperationRunOutcome::Succeeded->value; - if (! $run->user) { - return; + 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"; @@ -144,7 +232,12 @@ public function handle(BulkOperationService $service): void ->body($message) ->icon('heroicon-o-check-circle') ->success() - ->sendToDatabase($run->user) + ->actions($this->operationRun ? [ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ] : []) + ->sendToDatabase($user) ->send(); } } diff --git a/app/Jobs/BulkRestoreRunRestoreJob.php b/app/Jobs/BulkRestoreRunRestoreJob.php index 2b8efe4..cd91bc7 100644 --- a/app/Jobs/BulkRestoreRunRestoreJob.php +++ b/app/Jobs/BulkRestoreRunRestoreJob.php @@ -2,9 +2,15 @@ namespace App\Jobs; -use App\Models\BulkOperationRun; +use App\Jobs\Middleware\TrackOperationRun; +use App\Models\OperationRun; use App\Models\RestoreRun; -use App\Services\BulkOperationService; +use App\Models\Tenant; +use App\Models\User; +use App\Services\OperationRunService; +use App\Support\OperationRunLinks; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -17,19 +23,41 @@ class BulkRestoreRunRestoreJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public ?OperationRun $operationRun = null; + public function __construct( - public int $bulkRunId, - ) {} + public int $tenantId, + public int $userId, + /** @var array */ + public array $restoreRunIds, + ?OperationRun $operationRun = null, + ) { + $this->operationRun = $operationRun; + } - public function handle(BulkOperationService $service): void + public function middleware(): array { - $run = BulkOperationRun::with('user')->find($this->bulkRunId); + return [new TrackOperationRun]; + } - if (! $run || $run->status !== 'pending') { - return; + public function handle(OperationRunService $operationRunService): void + { + $tenant = Tenant::query()->find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new \RuntimeException('Tenant not found.'); } - $service->start($run); + $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; @@ -37,34 +65,56 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); - $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failures = []; + + $totalItems = count($ids); $failureThreshold = (int) floor($totalItems / 2); - foreach (($run->item_ids ?? []) as $restoreRunId) { + foreach ($ids as $restoreRunId) { $itemCount++; try { /** @var RestoreRun|null $restoreRun */ $restoreRun = RestoreRun::withTrashed() - ->where('tenant_id', $run->tenant_id) + ->where('tenant_id', $tenant->getKey()) ->whereKey($restoreRunId) ->first(); if (! $restoreRun) { - $service->recordFailure($run, (string) $restoreRunId, 'Restore run not found'); $failed++; + $failures[] = ['code' => 'restore_run.not_found', 'message' => "Restore run {$restoreRunId} not found."]; if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + 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, + ], + failures: array_merge($failures, [ + ['code' => 'restore_run.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'], + ]), + ); + } - if ($run->user) { + if ($user) { Notification::make() ->title('Bulk Restore Aborted') ->body('Circuit breaker triggered: too many failures (>50%).') ->icon('heroicon-o-exclamation-triangle') ->danger() - ->sendToDatabase($run->user) + ->actions($this->operationRun ? [ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ] : []) + ->sendToDatabase($user) ->send(); } @@ -75,7 +125,6 @@ public function handle(BulkOperationService $service): void } if (! $restoreRun->trashed()) { - $service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Not archived'); $skipped++; $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; @@ -83,38 +132,74 @@ public function handle(BulkOperationService $service): void } $restoreRun->restore(); - $service->recordSuccess($run); $succeeded++; } catch (Throwable $e) { - $service->recordFailure($run, (string) $restoreRunId, $e->getMessage()); $failed++; + $failures[] = ['code' => 'restore_run.restore.failed', 'message' => $e->getMessage()]; if ($failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + 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, + ], + failures: array_merge($failures, [ + ['code' => 'restore_run.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'], + ]), + ); + } - if ($run->user) { + if ($user) { Notification::make() ->title('Bulk Restore Aborted') ->body('Circuit breaker triggered: too many failures (>50%).') ->icon('heroicon-o-exclamation-triangle') ->danger() - ->sendToDatabase($run->user) + ->actions($this->operationRun ? [ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ] : []) + ->sendToDatabase($user) ->send(); } return; } } - - if ($itemCount % $chunkSize === 0) { - $run->refresh(); - } } - $service->complete($run); + $outcome = OperationRunOutcome::Succeeded->value; - if (! $run->user) { - return; + 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, + ], + failures: $failures, + ); } $message = "Restored {$succeeded} restore runs"; @@ -144,7 +229,12 @@ public function handle(BulkOperationService $service): void ->body($message) ->icon('heroicon-o-check-circle') ->success() - ->sendToDatabase($run->user) + ->actions($this->operationRun ? [ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($this->operationRun, $tenant)), + ] : []) + ->sendToDatabase($user) ->send(); } } diff --git a/app/Jobs/BulkTenantSyncJob.php b/app/Jobs/BulkTenantSyncJob.php index 50fd31e..93c3248 100644 --- a/app/Jobs/BulkTenantSyncJob.php +++ b/app/Jobs/BulkTenantSyncJob.php @@ -2,151 +2,92 @@ namespace App\Jobs; -use App\Models\BulkOperationRun; -use App\Models\Tenant; -use App\Services\BulkOperationService; -use App\Services\Intune\PolicySyncService; -use Filament\Notifications\Notification; +use App\Jobs\Operations\TenantSyncWorkerJob; +use App\Models\OperationRun; +use App\Services\OperationRunService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Throwable; +use RuntimeException; class BulkTenantSyncJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - public function __construct(public int $bulkRunId) {} + public ?OperationRun $operationRun = null; - public function handle(BulkOperationService $service, PolicySyncService $syncService): void + /** + * @param array $tenantIds + * @param array $context + */ + public function __construct( + public int $tenantId, + public int $userId, + public array $tenantIds, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } + + public function handle(OperationRunService $runs): void { - $run = BulkOperationRun::with(['tenant', 'user'])->find($this->bulkRunId); + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for BulkTenantSyncJob.'); + } - if (! $run || $run->status !== 'pending') { + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { return; } - $service->start($run); + $runs->updateRun($this->operationRun, 'running'); - try { - $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); - $itemCount = 0; + $ids = $this->normalizeIds($this->tenantIds); - $supported = config('tenantpilot.supported_policy_types'); + $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]); - $totalItems = $run->total_items ?: count($run->item_ids ?? []); - $failureThreshold = (int) floor($totalItems / 2); + $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10); + $chunkSize = max(1, $chunkSize); - foreach (($run->item_ids ?? []) as $tenantId) { - $itemCount++; - - try { - $tenant = Tenant::query()->whereKey($tenantId)->first(); - - if (! $tenant) { - $service->recordFailure($run, (string) $tenantId, 'Tenant not found'); - - if ($run->failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - - if ($run->user) { - Notification::make() - ->title('Bulk Sync Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - return; - } - - continue; - } - - if (! $tenant->isActive()) { - $service->recordSkippedWithReason($run, (string) $tenantId, 'Tenant is not active'); - - continue; - } - - if (! $run->user || ! $run->user->canSyncTenant($tenant)) { - $service->recordSkippedWithReason($run, (string) $tenantId, 'Not authorized to sync tenant'); - - continue; - } - - $syncService->syncPolicies($tenant, $supported); - - $service->recordSuccess($run); - } catch (Throwable $e) { - $service->recordFailure($run, (string) $tenantId, $e->getMessage()); - - if ($run->failed > $failureThreshold) { - $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); - - if ($run->user) { - Notification::make() - ->title('Bulk Sync 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(); - } + foreach (array_chunk($ids, $chunkSize) as $chunk) { + foreach ($chunk as $targetTenantId) { + dispatch(new TenantSyncWorkerJob( + tenantId: $targetTenantId, + userId: $this->userId, + operationRun: $this->operationRun, + context: $this->context, + )); } - - $service->complete($run); - - if ($run->user) { - $message = "Synced {$run->succeeded} tenant(s)"; - - if ($run->skipped > 0) { - $message .= " ({$run->skipped} skipped)"; - } - - if ($run->failed > 0) { - $message .= " ({$run->failed} failed)"; - } - - $message .= '.'; - - Notification::make() - ->title('Bulk Sync Completed') - ->body($message) - ->icon('heroicon-o-check-circle') - ->success() - ->sendToDatabase($run->user) - ->send(); - } - } catch (Throwable $e) { - $service->fail($run, $e->getMessage()); - - $run->refresh(); - $run->load('user'); - - if ($run->user) { - Notification::make() - ->title('Bulk Sync Failed') - ->body($e->getMessage()) - ->icon('heroicon-o-x-circle') - ->danger() - ->sendToDatabase($run->user) - ->send(); - } - - throw $e; } } + + /** + * @param array $ids + * @return array + */ + private function normalizeIds(array $ids): array + { + $normalized = []; + + foreach ($ids as $id) { + if (is_int($id)) { + $normalized[] = $id; + + continue; + } + + if (is_numeric($id)) { + $normalized[] = (int) $id; + } + } + + $normalized = array_values(array_unique($normalized)); + sort($normalized); + + return $normalized; + } } diff --git a/app/Jobs/CapturePolicySnapshotJob.php b/app/Jobs/CapturePolicySnapshotJob.php index f5f2917..51ffde1 100644 --- a/app/Jobs/CapturePolicySnapshotJob.php +++ b/app/Jobs/CapturePolicySnapshotJob.php @@ -2,17 +2,15 @@ namespace App\Jobs; -use App\Models\BulkOperationRun; -use App\Models\Policy; -use App\Notifications\RunStatusChangedNotification; -use App\Services\BulkOperationService; -use App\Services\Intune\VersionService; +use App\Jobs\Operations\CapturePolicySnapshotWorkerJob; +use App\Models\OperationRun; +use App\Services\OperationRunService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Throwable; +use RuntimeException; class CapturePolicySnapshotJob implements ShouldQueue { @@ -21,87 +19,48 @@ class CapturePolicySnapshotJob implements ShouldQueue use Queueable; use SerializesModels; + public ?OperationRun $operationRun = null; + + /** + * @param array $context + */ public function __construct( - public int $bulkOperationRunId, + public int $tenantId, + public int $userId, public int $policyId, public bool $includeAssignments = true, public bool $includeScopeTags = true, public ?string $createdBy = null, - ) {} - - public function handle(BulkOperationService $bulkOperationService, VersionService $versionService): void - { - $run = BulkOperationRun::query()->with(['tenant', 'user'])->find($this->bulkOperationRunId); - - if (! $run) { - return; - } - - $policy = Policy::query()->with('tenant')->find($this->policyId); - - if (! $policy || ! $policy->tenant) { - $bulkOperationService->abort($run, 'policy_not_found'); - $this->notifyStatus($run, 'failed'); - - return; - } - - $this->notifyStatus($run, 'queued'); - $bulkOperationService->start($run); - $this->notifyStatus($run, 'running'); - - try { - $versionService->captureFromGraph( - tenant: $policy->tenant, - policy: $policy, - createdBy: $this->createdBy, - includeAssignments: $this->includeAssignments, - includeScopeTags: $this->includeScopeTags, - ); - - $bulkOperationService->recordSuccess($run); - $bulkOperationService->complete($run); - - $this->notifyStatus($run, $run->refresh()->status); - } catch (Throwable $e) { - $bulkOperationService->recordFailure( - run: $run, - itemId: (string) $policy->getKey(), - reason: $bulkOperationService->sanitizeFailureReason($e->getMessage()) - ); - - $bulkOperationService->complete($run); - - $this->notifyStatus($run->refresh(), $run->status); - - throw $e; - } + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; } - private function notifyStatus(BulkOperationRun $run, string $status): void + public function handle(OperationRunService $runs): void { - if (! $run->relationLoaded('user')) { - $run->loadMissing('user'); + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for CapturePolicySnapshotJob.'); } - if (! $run->user) { + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { return; } - $normalizedStatus = $status === 'pending' ? 'queued' : $status; + $runs->updateRun($this->operationRun, 'running'); + $runs->incrementSummaryCounts($this->operationRun, ['total' => 1]); - $run->user->notify(new RunStatusChangedNotification([ - 'tenant_id' => (int) $run->tenant_id, - 'run_type' => 'bulk_operation', - 'run_id' => (int) $run->getKey(), - 'status' => (string) $normalizedStatus, - 'counts' => [ - 'total' => (int) $run->total_items, - 'processed' => (int) $run->processed_items, - 'succeeded' => (int) $run->succeeded, - 'failed' => (int) $run->failed, - 'skipped' => (int) $run->skipped, - ], - ])); + dispatch(new CapturePolicySnapshotWorkerJob( + tenantId: $this->tenantId, + userId: $this->userId, + policyId: $this->policyId, + includeAssignments: $this->includeAssignments, + includeScopeTags: $this->includeScopeTags, + createdBy: $this->createdBy, + operationRun: $this->operationRun, + context: $this->context, + )); } } diff --git a/app/Jobs/ExecuteRestoreRunJob.php b/app/Jobs/ExecuteRestoreRunJob.php index 47f1b0a..044a537 100644 --- a/app/Jobs/ExecuteRestoreRunJob.php +++ b/app/Jobs/ExecuteRestoreRunJob.php @@ -6,9 +6,9 @@ use App\Models\RestoreRun; use App\Models\User; use App\Notifications\RunStatusChangedNotification; -use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreService; +use App\Support\OpsUx\RunFailureSanitizer; use App\Support\RestoreRunStatus; use Carbon\CarbonImmutable; use Illuminate\Bus\Queueable; @@ -28,7 +28,7 @@ public function __construct( public ?string $actorName = null, ) {} - public function handle(RestoreService $restoreService, AuditLogger $auditLogger, BulkOperationService $bulkOperationService): void + public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void { $restoreRun = RestoreRun::with(['tenant', 'backupSet'])->find($this->restoreRunId); @@ -122,7 +122,7 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger, } catch (Throwable $throwable) { $restoreRun->refresh(); - $safeReason = $bulkOperationService->sanitizeFailureReason($throwable->getMessage()); + $safeReason = RunFailureSanitizer::sanitizeMessage($throwable->getMessage()); if ($restoreRun->status === RestoreRunStatus::Running->value) { $restoreRun->update([ diff --git a/app/Jobs/GenerateDriftFindingsJob.php b/app/Jobs/GenerateDriftFindingsJob.php index 3c66403..e0d5055 100644 --- a/app/Jobs/GenerateDriftFindingsJob.php +++ b/app/Jobs/GenerateDriftFindingsJob.php @@ -2,17 +2,12 @@ namespace App\Jobs; -use App\Jobs\Middleware\TrackOperationRun; -use App\Models\BulkOperationRun; use App\Models\InventorySyncRun; use App\Models\OperationRun; use App\Models\Tenant; -use App\Services\BulkOperationService; use App\Services\Drift\DriftFindingGenerator; use App\Services\OperationRunService; -use App\Support\OperationRunLinks; -use Filament\Actions\Action; -use Filament\Notifications\Notification; +use App\Services\Operations\TargetScopeConcurrencyLimiter; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -28,62 +23,78 @@ class GenerateDriftFindingsJob implements ShouldQueue public ?OperationRun $operationRun = null; + /** + * @param array $context + */ public function __construct( public int $tenantId, public int $userId, public int $baselineRunId, public int $currentRunId, public string $scopeKey, - public int $bulkOperationRunId, - ?OperationRun $operationRun = null + ?OperationRun $operationRun = null, + public array $context = [], ) { $this->operationRun = $operationRun; } - public function middleware(): array - { - return [new TrackOperationRun]; - } - - /** - * Execute the job. - */ - public function handle(DriftFindingGenerator $generator, BulkOperationService $bulkOperationService): void - { + public function handle( + DriftFindingGenerator $generator, + OperationRunService $runs, + TargetScopeConcurrencyLimiter $limiter, + ): void { Log::info('GenerateDriftFindingsJob: started', [ 'tenant_id' => $this->tenantId, 'baseline_run_id' => $this->baselineRunId, 'current_run_id' => $this->currentRunId, 'scope_key' => $this->scopeKey, - 'bulk_operation_run_id' => $this->bulkOperationRunId, ]); - $tenant = Tenant::query()->find($this->tenantId); - if (! $tenant instanceof Tenant) { - throw new RuntimeException('Tenant not found.'); + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for drift generation.'); } - $baseline = InventorySyncRun::query()->find($this->baselineRunId); - if (! $baseline instanceof InventorySyncRun) { - throw new RuntimeException('Baseline run not found.'); + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { + return; } - $current = InventorySyncRun::query()->find($this->currentRunId); - if (! $current instanceof InventorySyncRun) { - throw new RuntimeException('Current run not found.'); + $opContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $targetScope = is_array($opContext['target_scope'] ?? null) ? $opContext['target_scope'] : []; + + $lock = $limiter->acquireSlot($this->tenantId, $targetScope); + + if (! $lock) { + $delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3); + $this->release(max(1, $delay)); + + return; } - $run = BulkOperationRun::query() - ->where('tenant_id', $tenant->getKey()) - ->find($this->bulkOperationRunId); - - if (! $run instanceof BulkOperationRun) { - throw new RuntimeException('Bulk operation run not found.'); - } - - $bulkOperationService->start($run); - try { + $tenant = Tenant::query()->find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new RuntimeException('Tenant not found.'); + } + + $baseline = InventorySyncRun::query()->find($this->baselineRunId); + if (! $baseline instanceof InventorySyncRun) { + throw new RuntimeException('Baseline run not found.'); + } + + $current = InventorySyncRun::query()->find($this->currentRunId); + if (! $current instanceof InventorySyncRun) { + throw new RuntimeException('Current run not found.'); + } + + $runs->updateRun($this->operationRun, 'running'); + + $counts = is_array($this->operationRun->summary_counts ?? null) ? $this->operationRun->summary_counts : []; + if ((int) ($counts['total'] ?? 0) === 0) { + $runs->incrementSummaryCounts($this->operationRun, ['total' => 1]); + } + $created = $generator->generate( tenant: $tenant, baseline: $baseline, @@ -96,118 +107,40 @@ public function handle(DriftFindingGenerator $generator, BulkOperationService $b 'baseline_run_id' => $this->baselineRunId, 'current_run_id' => $this->currentRunId, 'scope_key' => $this->scopeKey, - 'bulk_operation_run_id' => $this->bulkOperationRunId, 'created_findings_count' => $created, ]); - $bulkOperationService->recordSuccess($run); - $bulkOperationService->complete($run); + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'succeeded' => 1, + 'created' => $created, + ]); - if ($this->operationRun) { - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opService->updateRun( - $this->operationRun, - 'completed', - 'succeeded', - ['findings_created' => $created] - ); - } - - $this->notifyStatus($run->refresh()); + $runs->maybeCompleteBulkRun($this->operationRun); } catch (Throwable $e) { Log::error('GenerateDriftFindingsJob: failed', [ 'tenant_id' => $this->tenantId, 'baseline_run_id' => $this->baselineRunId, 'current_run_id' => $this->currentRunId, 'scope_key' => $this->scopeKey, - 'bulk_operation_run_id' => $this->bulkOperationRunId, 'error' => $e->getMessage(), ]); - $bulkOperationService->recordFailure( - run: $run, - itemId: $this->scopeKey, - reason: $e->getMessage(), - reasonCode: 'unknown', - ); + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); - $bulkOperationService->fail($run, $e->getMessage()); + $runs->appendFailures($this->operationRun, [[ + 'code' => 'drift.generate.failed', + 'message' => $e->getMessage(), + ]]); - // TrackOperationRun middleware might catch this, but explicit fail ensures structure - if ($this->operationRun) { - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opService->failRun($this->operationRun, $e); - } - - $this->notifyStatus($run->refresh()); + $runs->maybeCompleteBulkRun($this->operationRun); throw $e; - } - } - - private function notifyStatus(BulkOperationRun $run): void - { - try { - if (! $run->relationLoaded('user')) { - $run->loadMissing('user'); - } - - if (! $run->user) { - return; - } - - $tenant = Tenant::query()->find((int) $run->tenant_id); - - if (! $tenant instanceof Tenant) { - return; - } - - $status = $run->statusBucket(); - - $title = match ($status) { - 'queued' => 'Drift generation queued', - 'running' => 'Drift generation started', - 'succeeded' => 'Drift generation completed', - 'partially succeeded' => 'Drift generation completed (partial)', - default => 'Drift generation failed', - }; - - $body = sprintf( - 'Total: %d, processed: %d, succeeded: %d, failed: %d, skipped: %d.', - (int) $run->total_items, - (int) $run->processed_items, - (int) $run->succeeded, - (int) $run->failed, - (int) $run->skipped, - ); - - $notification = Notification::make() - ->title($title) - ->body($body) - ->actions([ - Action::make('view_run') - ->label('View run') - ->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $tenant) : OperationRunLinks::index($tenant)), - ]); - - match ($status) { - 'succeeded' => $notification->success(), - 'partially succeeded' => $notification->warning(), - 'queued', 'running' => $notification->info(), - default => $notification->danger(), - }; - - $notification - ->sendToDatabase($run->user) - ->send(); - } catch (Throwable $e) { - Log::warning('GenerateDriftFindingsJob: status notification failed', [ - 'tenant_id' => (int) $run->tenant_id, - 'bulk_operation_run_id' => (int) $run->getKey(), - 'error' => $e->getMessage(), - ]); + } finally { + $lock->release(); } } } diff --git a/app/Jobs/Operations/BackupSetDeleteWorkerJob.php b/app/Jobs/Operations/BackupSetDeleteWorkerJob.php new file mode 100644 index 0000000..3a70e09 --- /dev/null +++ b/app/Jobs/Operations/BackupSetDeleteWorkerJob.php @@ -0,0 +1,120 @@ + $context + */ + public function __construct( + public int $tenantId, + public int $userId, + public int $backupSetId, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } + + public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void + { + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for backup set bulk delete worker.'); + } + + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { + return; + } + + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : []; + + $lock = $limiter->acquireSlot($this->tenantId, $targetScope); + + if (! $lock) { + $delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3); + $this->release(max(1, $delay)); + + return; + } + + try { + $backupSet = BackupSet::withTrashed() + ->where('tenant_id', $this->tenantId) + ->whereKey($this->backupSetId) + ->first(); + + if (! $backupSet instanceof BackupSet) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'backup_set.not_found', + 'message' => 'Backup set '.$this->backupSetId.' not found.', + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + if ($backupSet->trashed()) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + $backupSet->delete(); + + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'succeeded' => 1, + 'deleted' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + } catch (Throwable $e) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'backup_set.delete_failed', + 'message' => $e->getMessage(), + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + throw $e; + } finally { + $lock->release(); + } + } +} diff --git a/app/Jobs/Operations/BackupSetForceDeleteWorkerJob.php b/app/Jobs/Operations/BackupSetForceDeleteWorkerJob.php new file mode 100644 index 0000000..65d183d --- /dev/null +++ b/app/Jobs/Operations/BackupSetForceDeleteWorkerJob.php @@ -0,0 +1,137 @@ + $context + */ + public function __construct( + public int $tenantId, + public int $userId, + public int $backupSetId, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } + + public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void + { + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for backup set bulk force delete worker.'); + } + + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { + return; + } + + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : []; + + $lock = $limiter->acquireSlot($this->tenantId, $targetScope); + + if (! $lock) { + $delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3); + $this->release(max(1, $delay)); + + return; + } + + try { + $backupSet = BackupSet::withTrashed() + ->where('tenant_id', $this->tenantId) + ->whereKey($this->backupSetId) + ->first(); + + if (! $backupSet instanceof BackupSet) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'backup_set.not_found', + 'message' => 'Backup set '.$this->backupSetId.' not found.', + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + if (! $backupSet->trashed()) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + if ($backupSet->restoreRuns()->withTrashed()->exists()) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'backup_set.referenced_by_restore_runs', + 'message' => 'Backup set '.$this->backupSetId.' is referenced by restore runs and cannot be force deleted.', + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + $backupSet->items()->withTrashed()->forceDelete(); + $backupSet->forceDelete(); + + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'succeeded' => 1, + 'deleted' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + } catch (Throwable $e) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'backup_set.force_delete_failed', + 'message' => $e->getMessage(), + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + throw $e; + } finally { + $lock->release(); + } + } +} diff --git a/app/Jobs/Operations/BackupSetRestoreWorkerJob.php b/app/Jobs/Operations/BackupSetRestoreWorkerJob.php new file mode 100644 index 0000000..0d59c60 --- /dev/null +++ b/app/Jobs/Operations/BackupSetRestoreWorkerJob.php @@ -0,0 +1,121 @@ + $context + */ + public function __construct( + public int $tenantId, + public int $userId, + public int $backupSetId, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } + + public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void + { + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for backup set bulk restore worker.'); + } + + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { + return; + } + + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : []; + + $lock = $limiter->acquireSlot($this->tenantId, $targetScope); + + if (! $lock) { + $delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3); + $this->release(max(1, $delay)); + + return; + } + + try { + $backupSet = BackupSet::withTrashed() + ->where('tenant_id', $this->tenantId) + ->whereKey($this->backupSetId) + ->first(); + + if (! $backupSet instanceof BackupSet) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'backup_set.not_found', + 'message' => 'Backup set '.$this->backupSetId.' not found.', + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + if (! $backupSet->trashed()) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + $backupSet->restore(); + $backupSet->items()->withTrashed()->restore(); + + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'succeeded' => 1, + 'updated' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + } catch (Throwable $e) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'backup_set.restore_failed', + 'message' => $e->getMessage(), + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + throw $e; + } finally { + $lock->release(); + } + } +} diff --git a/app/Jobs/Operations/BulkOperationOrchestratorJob.php b/app/Jobs/Operations/BulkOperationOrchestratorJob.php new file mode 100644 index 0000000..d89ba63 --- /dev/null +++ b/app/Jobs/Operations/BulkOperationOrchestratorJob.php @@ -0,0 +1,101 @@ + $itemIds + * @param array $context + */ + public function __construct( + public int $tenantId, + public int $userId, + public array $itemIds, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + $this->itemIds = $this->normalizeItemIds($itemIds); + } + + /** + * @return array + */ + public function middleware(): array + { + return []; + } + + public function handle(OperationRunService $runs): void + { + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for bulk orchestrator jobs.'); + } + + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { + return; + } + + $runs->updateRun($this->operationRun, 'running'); + + $runs->incrementSummaryCounts($this->operationRun, ['total' => count($this->itemIds)]); + + $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10); + $chunkSize = max(1, $chunkSize); + + foreach (array_chunk($this->itemIds, $chunkSize) as $chunk) { + foreach ($chunk as $itemId) { + dispatch($this->makeWorkerJob($itemId)); + } + } + } + + abstract protected function makeWorkerJob(string $itemId): ShouldQueue; + + /** + * @param array $itemIds + * @return array + */ + protected function normalizeItemIds(array $itemIds): array + { + $normalized = []; + + foreach ($itemIds as $itemId) { + if (is_int($itemId)) { + $itemId = (string) $itemId; + } + + if (! is_string($itemId)) { + continue; + } + + $itemId = trim($itemId); + if ($itemId === '') { + continue; + } + + $normalized[] = $itemId; + } + + $normalized = array_values(array_unique($normalized)); + sort($normalized); + + return $normalized; + } +} diff --git a/app/Jobs/Operations/BulkOperationWorkerJob.php b/app/Jobs/Operations/BulkOperationWorkerJob.php new file mode 100644 index 0000000..5b29736 --- /dev/null +++ b/app/Jobs/Operations/BulkOperationWorkerJob.php @@ -0,0 +1,66 @@ + $context + */ + public function __construct( + public int $tenantId, + public int $userId, + public string $itemId, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } + + /** + * @return array + */ + public function middleware(): array + { + return []; + } + + public function handle(OperationRunService $runs): void + { + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for bulk worker jobs.'); + } + + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { + return; + } + + try { + $this->process($runs); + } catch (Throwable $e) { + $runs->failRun($this->operationRun, $e); + + throw $e; + } + } + + abstract protected function process(OperationRunService $runs): void; +} diff --git a/app/Jobs/Operations/CapturePolicySnapshotWorkerJob.php b/app/Jobs/Operations/CapturePolicySnapshotWorkerJob.php new file mode 100644 index 0000000..8ce6942 --- /dev/null +++ b/app/Jobs/Operations/CapturePolicySnapshotWorkerJob.php @@ -0,0 +1,124 @@ + $context + */ + public function __construct( + public int $tenantId, + public int $userId, + public int $policyId, + public bool $includeAssignments = true, + public bool $includeScopeTags = true, + public ?string $createdBy = null, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } + + public function handle( + OperationRunService $runs, + TargetScopeConcurrencyLimiter $limiter, + VersionService $versionService, + ): void { + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for policy snapshot capture worker.'); + } + + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { + return; + } + + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : []; + + $lock = $limiter->acquireSlot($this->tenantId, $targetScope); + + if (! $lock) { + $delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3); + $this->release(max(1, $delay)); + + return; + } + + try { + $policy = Policy::query() + ->with('tenant') + ->where('tenant_id', $this->tenantId) + ->whereKey($this->policyId) + ->first(); + + if (! $policy instanceof Policy || ! $policy->tenant) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'policy.not_found', + 'message' => 'Policy '.$this->policyId.' not found.', + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + $versionService->captureFromGraph( + tenant: $policy->tenant, + policy: $policy, + createdBy: $this->createdBy, + includeAssignments: $this->includeAssignments, + includeScopeTags: $this->includeScopeTags, + ); + + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'succeeded' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + } catch (Throwable $e) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'policy.capture_snapshot.failed', + 'message' => $e->getMessage(), + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + throw $e; + } finally { + $lock->release(); + } + } +} diff --git a/app/Jobs/Operations/PolicyBulkDeleteWorkerJob.php b/app/Jobs/Operations/PolicyBulkDeleteWorkerJob.php new file mode 100644 index 0000000..5110449 --- /dev/null +++ b/app/Jobs/Operations/PolicyBulkDeleteWorkerJob.php @@ -0,0 +1,120 @@ + $context + */ + public function __construct( + public int $tenantId, + public int $userId, + public int $policyId, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } + + public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void + { + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for policy bulk delete worker.'); + } + + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { + return; + } + + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : []; + + $lock = $limiter->acquireSlot($this->tenantId, $targetScope); + + if (! $lock) { + $delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3); + $this->release(max(1, $delay)); + + return; + } + + try { + $policy = Policy::query() + ->where('tenant_id', $this->tenantId) + ->whereKey($this->policyId) + ->first(); + + if (! $policy instanceof Policy) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'policy.not_found', + 'message' => 'Policy '.$this->policyId.' not found.', + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + if ($policy->ignored_at) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + $policy->ignore(); + + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'succeeded' => 1, + 'deleted' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + } catch (Throwable $e) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'policy.delete_failed', + 'message' => $e->getMessage(), + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + throw $e; + } finally { + $lock->release(); + } + } +} diff --git a/app/Jobs/Operations/PolicyVersionForceDeleteWorkerJob.php b/app/Jobs/Operations/PolicyVersionForceDeleteWorkerJob.php new file mode 100644 index 0000000..5c13158 --- /dev/null +++ b/app/Jobs/Operations/PolicyVersionForceDeleteWorkerJob.php @@ -0,0 +1,120 @@ + $context + */ + public function __construct( + public int $tenantId, + public int $userId, + public int $policyVersionId, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } + + public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void + { + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for policy version force delete worker.'); + } + + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { + return; + } + + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : []; + + $lock = $limiter->acquireSlot($this->tenantId, $targetScope); + + if (! $lock) { + $delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3); + $this->release(max(1, $delay)); + + return; + } + + try { + $version = PolicyVersion::withTrashed() + ->where('tenant_id', $this->tenantId) + ->whereKey($this->policyVersionId) + ->first(); + + if (! $version instanceof PolicyVersion) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'policy_version.not_found', + 'message' => 'Policy version '.$this->policyVersionId.' not found.', + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + if (! $version->trashed()) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + $version->forceDelete(); + + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'succeeded' => 1, + 'deleted' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + } catch (Throwable $e) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'policy_version.force_delete_failed', + 'message' => $e->getMessage(), + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + throw $e; + } finally { + $lock->release(); + } + } +} diff --git a/app/Jobs/Operations/PolicyVersionPruneWorkerJob.php b/app/Jobs/Operations/PolicyVersionPruneWorkerJob.php new file mode 100644 index 0000000..67f2236 --- /dev/null +++ b/app/Jobs/Operations/PolicyVersionPruneWorkerJob.php @@ -0,0 +1,138 @@ + $context + */ + public function __construct( + public int $tenantId, + public int $userId, + public int $policyVersionId, + public int $retentionDays = 90, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } + + public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void + { + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for policy version prune worker.'); + } + + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { + return; + } + + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : []; + + $lock = $limiter->acquireSlot($this->tenantId, $targetScope); + + if (! $lock) { + $delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3); + $this->release(max(1, $delay)); + + return; + } + + try { + $version = PolicyVersion::withTrashed() + ->where('tenant_id', $this->tenantId) + ->whereKey($this->policyVersionId) + ->first(); + + if (! $version instanceof PolicyVersion) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'policy_version.not_found', + 'message' => 'Policy version '.$this->policyVersionId.' not found.', + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + if ($version->trashed()) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + $eligible = PolicyVersion::query() + ->where('tenant_id', $this->tenantId) + ->whereKey($version->id) + ->pruneEligible($this->retentionDays) + ->exists(); + + if (! $eligible) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + $version->delete(); + + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'succeeded' => 1, + 'deleted' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + } catch (Throwable $e) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'policy_version.prune_failed', + 'message' => $e->getMessage(), + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + throw $e; + } finally { + $lock->release(); + } + } +} diff --git a/app/Jobs/Operations/PolicyVersionRestoreWorkerJob.php b/app/Jobs/Operations/PolicyVersionRestoreWorkerJob.php new file mode 100644 index 0000000..0dc9d84 --- /dev/null +++ b/app/Jobs/Operations/PolicyVersionRestoreWorkerJob.php @@ -0,0 +1,120 @@ + $context + */ + public function __construct( + public int $tenantId, + public int $userId, + public int $policyVersionId, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } + + public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void + { + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for policy version restore worker.'); + } + + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { + return; + } + + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : []; + + $lock = $limiter->acquireSlot($this->tenantId, $targetScope); + + if (! $lock) { + $delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3); + $this->release(max(1, $delay)); + + return; + } + + try { + $version = PolicyVersion::withTrashed() + ->where('tenant_id', $this->tenantId) + ->whereKey($this->policyVersionId) + ->first(); + + if (! $version instanceof PolicyVersion) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'policy_version.not_found', + 'message' => 'Policy version '.$this->policyVersionId.' not found.', + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + if (! $version->trashed()) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + $version->restore(); + + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'succeeded' => 1, + 'updated' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + } catch (Throwable $e) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'policy_version.restore_failed', + 'message' => $e->getMessage(), + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + throw $e; + } finally { + $lock->release(); + } + } +} diff --git a/app/Jobs/Operations/RestoreRunDeleteWorkerJob.php b/app/Jobs/Operations/RestoreRunDeleteWorkerJob.php new file mode 100644 index 0000000..2afaf1a --- /dev/null +++ b/app/Jobs/Operations/RestoreRunDeleteWorkerJob.php @@ -0,0 +1,131 @@ + $context + */ + public function __construct( + public int $tenantId, + public int $userId, + public int $restoreRunId, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } + + public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void + { + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for restore run delete worker.'); + } + + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { + return; + } + + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : []; + + $lock = $limiter->acquireSlot($this->tenantId, $targetScope); + + if (! $lock) { + $delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3); + $this->release(max(1, $delay)); + + return; + } + + try { + $restoreRun = RestoreRun::withTrashed() + ->where('tenant_id', $this->tenantId) + ->whereKey($this->restoreRunId) + ->first(); + + if (! $restoreRun instanceof RestoreRun) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'restore_run.not_found', + 'message' => 'Restore run '.$this->restoreRunId.' not found.', + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + if ($restoreRun->trashed()) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + if (! $restoreRun->isDeletable()) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + $restoreRun->delete(); + + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'succeeded' => 1, + 'deleted' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + } catch (Throwable $e) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'restore_run.delete_failed', + 'message' => $e->getMessage(), + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + throw $e; + } finally { + $lock->release(); + } + } +} diff --git a/app/Jobs/Operations/TenantSyncWorkerJob.php b/app/Jobs/Operations/TenantSyncWorkerJob.php new file mode 100644 index 0000000..d57e21e --- /dev/null +++ b/app/Jobs/Operations/TenantSyncWorkerJob.php @@ -0,0 +1,136 @@ + $context + */ + public function __construct( + public int $tenantId, + public int $userId, + ?OperationRun $operationRun = null, + public array $context = [], + ) { + $this->operationRun = $operationRun; + } + + public function handle( + OperationRunService $runs, + TargetScopeConcurrencyLimiter $limiter, + PolicySyncService $syncService, + ): void { + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for tenant sync worker.'); + } + + $this->operationRun->refresh(); + + if ($this->operationRun->status === 'completed') { + return; + } + + $lock = null; + + try { + $tenant = Tenant::query()->whereKey($this->tenantId)->first(); + + if (! $tenant instanceof Tenant) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'tenant.not_found', + 'message' => 'Tenant '.$this->tenantId.' not found.', + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + $lock = $limiter->acquireSlot($tenant->getKey(), [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ]); + + if (! $lock) { + $delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3); + $this->release(max(1, $delay)); + + return; + } + + if (! $tenant->isActive()) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + $user = User::query()->whereKey($this->userId)->first(); + + if (! $user instanceof User || ! $user->canSyncTenant($tenant)) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'skipped' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + return; + } + + $supported = config('tenantpilot.supported_policy_types', []); + + $syncService->syncPolicies($tenant, $supported); + + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'succeeded' => 1, + ]); + + $runs->maybeCompleteBulkRun($this->operationRun); + } catch (Throwable $e) { + $runs->incrementSummaryCounts($this->operationRun, [ + 'processed' => 1, + 'failed' => 1, + ]); + + $runs->appendFailures($this->operationRun, [[ + 'code' => 'tenant.sync_failed', + 'message' => $e->getMessage(), + ]]); + + $runs->maybeCompleteBulkRun($this->operationRun); + + throw $e; + } finally { + $lock?->release(); + } + } +} diff --git a/app/Jobs/ReconcileAdapterRunsJob.php b/app/Jobs/ReconcileAdapterRunsJob.php new file mode 100644 index 0000000..1ba1a5b --- /dev/null +++ b/app/Jobs/ReconcileAdapterRunsJob.php @@ -0,0 +1,51 @@ +reconcile([ + 'older_than_minutes' => 60, + 'limit' => 50, + 'dry_run' => false, + ]); + + Log::info('ReconcileAdapterRunsJob completed', [ + 'candidates' => (int) ($result['candidates'] ?? 0), + 'reconciled' => (int) ($result['reconciled'] ?? 0), + 'skipped' => (int) ($result['skipped'] ?? 0), + ]); + } catch (Throwable $e) { + Log::warning('ReconcileAdapterRunsJob failed', [ + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } +} diff --git a/app/Jobs/RemovePoliciesFromBackupSetJob.php b/app/Jobs/RemovePoliciesFromBackupSetJob.php index ffa3fea..c9570b5 100644 --- a/app/Jobs/RemovePoliciesFromBackupSetJob.php +++ b/app/Jobs/RemovePoliciesFromBackupSetJob.php @@ -8,10 +8,10 @@ use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; -use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Support\OperationRunLinks; +use App\Support\OpsUx\RunFailureSanitizer; use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -48,7 +48,6 @@ public function middleware(): array public function handle( AuditLogger $auditLogger, - BulkOperationService $bulkOperationService, ): void { $backupSet = BackupSet::query()->with(['tenant'])->find($this->backupSetId); @@ -60,7 +59,7 @@ public function handle( $this->operationRun, 'completed', 'failed', - ['backup_set_id' => $this->backupSetId], + ['failed' => 1], [['code' => 'backup_set.not_found', 'message' => 'Backup set not found.']] ); } @@ -97,7 +96,7 @@ public function handle( foreach ($missingIds as $missingId) { $failures[] = [ 'code' => 'backup_item.not_found', - 'message' => $bulkOperationService->sanitizeFailureReason("Backup item {$missingId} not found (already removed?)."), + 'message' => RunFailureSanitizer::sanitizeMessage("Backup item {$missingId} not found (already removed?)."), ]; } @@ -148,6 +147,16 @@ public function handle( /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); + $this->operationRun->update([ + 'context' => array_merge($this->operationRun->context ?? [], [ + 'backup_set_id' => (int) $backupSet->getKey(), + 'requested_count' => $requestedCount, + 'removed_count' => $removed, + 'missing_count' => count($missingIds), + 'remaining_count' => (int) $backupSet->item_count, + ]), + ]); + $outcome = 'succeeded'; if ($removed === 0) { $outcome = 'failed'; @@ -160,11 +169,12 @@ public function handle( 'completed', $outcome, [ - 'backup_set_id' => (int) $backupSet->getKey(), - 'requested' => $requestedCount, - 'removed' => $removed, - 'missing' => count($missingIds), - 'remaining' => (int) $backupSet->item_count, + 'total' => $requestedCount, + 'processed' => $requestedCount, + 'succeeded' => $removed, + 'failed' => count($missingIds), + 'deleted' => $removed, + 'items' => $requestedCount, ], $failures, ); @@ -205,7 +215,7 @@ public function handle( $this->notifyFailed( initiator: $initiator, tenant: $tenant instanceof Tenant ? $tenant : null, - reason: $bulkOperationService->sanitizeFailureReason($throwable->getMessage()), + reason: RunFailureSanitizer::sanitizeMessage($throwable->getMessage()), ); throw $throwable; diff --git a/app/Jobs/RunBackupScheduleJob.php b/app/Jobs/RunBackupScheduleJob.php index 27e196a..26dfc0d 100644 --- a/app/Jobs/RunBackupScheduleJob.php +++ b/app/Jobs/RunBackupScheduleJob.php @@ -5,13 +5,11 @@ use App\Jobs\Middleware\TrackOperationRun; use App\Models\BackupSchedule; use App\Models\BackupScheduleRun; -use App\Models\BulkOperationRun; use App\Models\OperationRun; use App\Models\Tenant; use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\BackupScheduling\RunErrorMapper; use App\Services\BackupScheduling\ScheduleTimeService; -use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Intune\BackupService; use App\Services\Intune\PolicySyncService; @@ -39,7 +37,6 @@ class RunBackupScheduleJob implements ShouldQueue public function __construct( public int $backupScheduleRunId, - public ?int $bulkRunId = null, ?OperationRun $operationRun = null, ) { $this->operationRun = $operationRun; @@ -57,7 +54,6 @@ public function handle( ScheduleTimeService $scheduleTimeService, AuditLogger $auditLogger, RunErrorMapper $errorMapper, - BulkOperationService $bulkOperationService, ): void { $run = BackupScheduleRun::query() ->with(['schedule', 'tenant', 'user']) @@ -67,9 +63,8 @@ public function handle( if ($this->operationRun) { $this->markOperationRunFailed( run: $this->operationRun, - bulkOperationService: $bulkOperationService, summaryCounts: [], - reasonCode: 'RUN_NOT_FOUND', + reasonCode: 'run_not_found', reason: 'Backup schedule run not found.', ); } @@ -99,21 +94,6 @@ public function handle( } } - $bulkRun = $this->bulkRunId - ? BulkOperationRun::query()->with(['tenant', 'user'])->find($this->bulkRunId) - : null; - - if ( - $bulkRun - && ($bulkRun->tenant_id !== $run->tenant_id || $bulkRun->user_id !== $run->user_id) - ) { - $bulkRun = null; - } - - if ($bulkRun && $bulkRun->status === 'pending') { - $bulkOperationService->start($bulkRun); - } - $schedule = $run->schedule; if (! $schedule instanceof BackupSchedule) { @@ -127,12 +107,12 @@ public function handle( if ($this->operationRun) { $this->markOperationRunFailed( run: $this->operationRun, - bulkOperationService: $bulkOperationService, summaryCounts: [ - 'backup_schedule_id' => (int) $run->backup_schedule_id, - 'backup_schedule_run_id' => (int) $run->getKey(), + 'total' => 0, + 'processed' => 0, + 'failed' => 1, ], - reasonCode: 'SCHEDULE_NOT_FOUND', + reasonCode: 'schedule_not_found', reason: 'Schedule not found.', ); } @@ -151,12 +131,12 @@ public function handle( if ($this->operationRun) { $this->markOperationRunFailed( run: $this->operationRun, - bulkOperationService: $bulkOperationService, summaryCounts: [ - 'backup_schedule_id' => (int) $run->backup_schedule_id, - 'backup_schedule_run_id' => (int) $run->getKey(), + 'total' => 0, + 'processed' => 0, + 'failed' => 1, ], - reasonCode: 'TENANT_NOT_FOUND', + reasonCode: 'tenant_not_found', reason: 'Tenant not found.', ); } @@ -175,14 +155,12 @@ public function handle( errorMessage: 'Another run is already in progress for this schedule.', summary: ['reason' => 'concurrent_run'], scheduleTimeService: $scheduleTimeService, - bulkRunId: $this->bulkRunId, ); $this->syncOperationRunFromRun( tenant: $tenant, schedule: $schedule, run: $run->refresh(), - bulkOperationService: $bulkOperationService, ); return; @@ -228,14 +206,12 @@ public function handle( 'unknown_policy_types' => $unknownTypes, ], scheduleTimeService: $scheduleTimeService, - bulkRunId: $this->bulkRunId, ); $this->syncOperationRunFromRun( tenant: $tenant, schedule: $schedule, run: $run->refresh(), - bulkOperationService: $bulkOperationService, ); return; @@ -294,14 +270,12 @@ public function handle( summary: $summary, scheduleTimeService: $scheduleTimeService, backupSetId: (string) $backupSet->id, - bulkRunId: $this->bulkRunId, ); $this->syncOperationRunFromRun( tenant: $tenant, schedule: $schedule, run: $run->refresh(), - bulkOperationService: $bulkOperationService, ); $auditLogger->log( @@ -346,14 +320,12 @@ public function handle( 'attempt' => $attempt, ], scheduleTimeService: $scheduleTimeService, - bulkRunId: $this->bulkRunId, ); $this->syncOperationRunFromRun( tenant: $tenant, schedule: $schedule, run: $run->refresh(), - bulkOperationService: $bulkOperationService, ); $auditLogger->log( @@ -438,7 +410,6 @@ private function syncOperationRunFromRun( Tenant $tenant, BackupSchedule $schedule, BackupScheduleRun $run, - BulkOperationService $bulkOperationService, ): void { if (! $this->operationRun) { return; @@ -457,23 +428,29 @@ private function syncOperationRunFromRun( $summary = is_array($run->summary) ? $run->summary : []; $syncFailures = $summary['sync_failures'] ?? []; - $summaryCounts = [ - 'backup_schedule_id' => (int) $schedule->getKey(), - 'backup_schedule_run_id' => (int) $run->getKey(), - 'backup_set_id' => $run->backup_set_id ? (int) $run->backup_set_id : null, - 'policies_total' => (int) ($summary['policies_total'] ?? 0), - 'policies_backed_up' => (int) ($summary['policies_backed_up'] ?? 0), - 'sync_failures' => is_array($syncFailures) ? count($syncFailures) : 0, - ]; + $policiesTotal = (int) ($summary['policies_total'] ?? 0); + $policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0); + $syncFailureCount = is_array($syncFailures) ? count($syncFailures) : 0; - $summaryCounts = array_filter($summaryCounts, fn (mixed $value): bool => $value !== null); + $failedCount = max(0, $policiesTotal - $policiesBackedUp); + + $summaryCounts = [ + 'total' => $policiesTotal, + 'processed' => $policiesTotal, + 'succeeded' => $policiesBackedUp, + 'failed' => $failedCount, + 'skipped' => 0, + 'created' => filled($run->backup_set_id) ? 1 : 0, + 'updated' => $policiesBackedUp, + 'items' => $policiesTotal, + ]; $failures = []; if (filled($run->error_message) || filled($run->error_code)) { $failures[] = [ - 'code' => (string) ($run->error_code ?: 'BACKUP_SCHEDULE_ERROR'), - 'message' => $bulkOperationService->sanitizeFailureReason((string) ($run->error_message ?: 'Backup schedule run failed.')), + 'code' => strtolower((string) ($run->error_code ?: 'backup_schedule_error')), + 'message' => (string) ($run->error_message ?: 'Backup schedule run failed.'), ]; } @@ -501,8 +478,8 @@ private function syncOperationRunFromRun( } $failures[] = [ - 'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR', - 'message' => $bulkOperationService->sanitizeFailureReason($message), + 'code' => $status !== null ? 'graph_http_'.(string) $status : 'graph_error', + 'message' => $message, ]; } } @@ -514,6 +491,7 @@ private function syncOperationRunFromRun( 'context' => array_merge($this->operationRun->context ?? [], [ 'backup_schedule_id' => (int) $schedule->getKey(), 'backup_schedule_run_id' => (int) $run->getKey(), + 'backup_set_id' => $run->backup_set_id ? (int) $run->backup_set_id : null, ]), ]); @@ -528,7 +506,6 @@ private function syncOperationRunFromRun( private function markOperationRunFailed( OperationRun $run, - BulkOperationService $bulkOperationService, array $summaryCounts, string $reasonCode, string $reason, @@ -544,7 +521,7 @@ private function markOperationRunFailed( failures: [ [ 'code' => $reasonCode, - 'message' => $bulkOperationService->sanitizeFailureReason($reason), + 'message' => $reason, ], ], ); @@ -578,7 +555,6 @@ private function finishRun( array $summary, ScheduleTimeService $scheduleTimeService, ?string $backupSetId = null, - ?int $bulkRunId = null, ): void { $nowUtc = CarbonImmutable::now('UTC'); @@ -599,48 +575,6 @@ private function finishRun( $this->notifyRunFinished($run, $schedule); - if ($bulkRunId) { - $bulkRun = BulkOperationRun::query()->with(['tenant', 'user'])->find($bulkRunId); - - if ( - $bulkRun - && ($bulkRun->tenant_id === $run->tenant_id) - && ($bulkRun->user_id === $run->user_id) - && in_array($bulkRun->status, ['pending', 'running'], true) - ) { - $service = app(BulkOperationService::class); - - $itemId = (string) $run->backup_schedule_id; - - match ($status) { - BackupScheduleRun::STATUS_SUCCESS => $service->recordSuccess($bulkRun), - BackupScheduleRun::STATUS_SKIPPED => $service->recordSkippedWithReason( - $bulkRun, - $itemId, - $errorMessage ?: 'Skipped', - ), - BackupScheduleRun::STATUS_PARTIAL => $service->recordFailure( - $bulkRun, - $itemId, - $errorMessage ?: 'Completed partially', - ), - default => $service->recordFailure( - $bulkRun, - $itemId, - $errorMessage ?: ($errorCode ?: 'Failed'), - ), - }; - - $bulkRun->refresh(); - if ( - in_array($bulkRun->status, ['pending', 'running'], true) - && $bulkRun->processed_items >= $bulkRun->total_items - ) { - $service->complete($bulkRun); - } - } - } - if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) { Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id)); } diff --git a/app/Jobs/RunInventorySyncJob.php b/app/Jobs/RunInventorySyncJob.php index 2e1e18b..530fe57 100644 --- a/app/Jobs/RunInventorySyncJob.php +++ b/app/Jobs/RunInventorySyncJob.php @@ -3,16 +3,16 @@ namespace App\Jobs; use App\Jobs\Middleware\TrackOperationRun; -use App\Models\BulkOperationRun; use App\Models\InventorySyncRun; use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; -use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Inventory\InventorySyncService; use App\Services\OperationRunService; use App\Support\OperationRunLinks; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -33,7 +33,6 @@ class RunInventorySyncJob implements ShouldQueue public function __construct( public int $tenantId, public int $userId, - public int $bulkRunId, public int $inventorySyncRunId, ?OperationRun $operationRun = null ) { @@ -53,7 +52,7 @@ public function middleware(): array /** * Execute the job. */ - public function handle(BulkOperationService $bulkOperationService, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void + public function handle(InventorySyncService $inventorySyncService, AuditLogger $auditLogger, OperationRunService $operationRunService): void { $tenant = Tenant::query()->find($this->tenantId); if (! $tenant instanceof Tenant) { @@ -65,19 +64,19 @@ public function handle(BulkOperationService $bulkOperationService, InventorySync throw new RuntimeException('User not found.'); } - $bulkRun = BulkOperationRun::query()->find($this->bulkRunId); - if (! $bulkRun instanceof BulkOperationRun) { - throw new RuntimeException('BulkOperationRun not found.'); - } - $run = InventorySyncRun::query()->find($this->inventorySyncRunId); if (! $run instanceof InventorySyncRun) { throw new RuntimeException('InventorySyncRun not found.'); } - $bulkOperationService->start($bulkRun); + $policyTypes = is_array($run->selection_payload['policy_types'] ?? null) ? $run->selection_payload['policy_types'] : []; + if (! is_array($policyTypes)) { + $policyTypes = []; + } $processedPolicyTypes = []; + $successCount = 0; + $failedCount = 0; // Note: The TrackOperationRun middleware will automatically set status to 'running' at start. // It will also handle success completion if no exceptions thrown. @@ -87,48 +86,36 @@ public function handle(BulkOperationService $bulkOperationService, InventorySync $run = $inventorySyncService->executePendingRun( $run, $tenant, - function (string $policyType, bool $success, ?string $errorCode) use ($bulkOperationService, $bulkRun, &$processedPolicyTypes): void { + function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$successCount, &$failedCount): void { $processedPolicyTypes[] = $policyType; if ($success) { - $bulkOperationService->recordSuccess($bulkRun); + $successCount++; return; } - $bulkOperationService->recordFailure($bulkRun, $policyType, $errorCode ?? 'failed'); + $failedCount++; }, ); - $policyTypes = is_array($bulkRun->item_ids ?? null) ? $bulkRun->item_ids : []; - if ($policyTypes === []) { - $policyTypes = is_array($run->selection_payload['policy_types'] ?? null) ? $run->selection_payload['policy_types'] : []; - } - - // --- Helper to update OperationRun with rich context --- - $updateOpRun = function (string $outcome, array $counts = [], array $failures = []) { + if ($run->status === InventorySyncRun::STATUS_SUCCESS) { if ($this->operationRun) { - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opService->updateRun( + $operationRunService->updateRun( $this->operationRun, - 'completed', - $outcome, - $counts, - $failures + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + summaryCounts: [ + 'total' => count($policyTypes), + 'processed' => count($policyTypes), + 'succeeded' => count($policyTypes), + 'failed' => 0, + // Reuse allowed keys for inventory item stats. + 'items' => (int) $run->items_observed_count, + 'updated' => (int) $run->items_upserted_count, + ], ); } - }; - // ----------------------------------------------------- - - if ($run->status === InventorySyncRun::STATUS_SUCCESS) { - $bulkOperationService->complete($bulkRun); - - // Update Operation Run explicitly to provide counts - $updateOpRun('succeeded', [ - 'observed' => $run->items_observed_count, - 'upserted' => $run->items_upserted_count, - ]); $auditLogger->log( tenant: $tenant, @@ -136,7 +123,6 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera context: [ 'metadata' => [ 'inventory_sync_run_id' => $run->id, - 'bulk_run_id' => $bulkRun->id, 'selection_hash' => $run->selection_hash, 'observed' => $run->items_observed_count, 'upserted' => $run->items_upserted_count, @@ -165,16 +151,24 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera } if ($run->status === InventorySyncRun::STATUS_PARTIAL) { - $bulkOperationService->complete($bulkRun); - - $updateOpRun('partially_succeeded', [ - 'observed' => $run->items_observed_count, - 'upserted' => $run->items_upserted_count, - 'errors' => $run->errors_count, - ], [ - // Minimal error summary - ['code' => 'PARTIAL_SYNC', 'message' => "Errors: {$run->errors_count}"], - ]); + if ($this->operationRun) { + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::PartiallySucceeded->value, + summaryCounts: [ + 'total' => count($policyTypes), + 'processed' => count($policyTypes), + 'succeeded' => max(0, count($policyTypes) - (int) $run->errors_count), + 'failed' => (int) $run->errors_count, + 'items' => (int) $run->items_observed_count, + 'updated' => (int) $run->items_upserted_count, + ], + failures: [ + ['code' => 'inventory.partial', 'message' => "Errors: {$run->errors_count}"], + ], + ); + } $auditLogger->log( tenant: $tenant, @@ -182,7 +176,6 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera context: [ 'metadata' => [ 'inventory_sync_run_id' => $run->id, - 'bulk_run_id' => $bulkRun->id, 'selection_hash' => $run->selection_hash, 'observed' => $run->items_observed_count, 'upserted' => $run->items_upserted_count, @@ -215,12 +208,23 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera if ($run->status === InventorySyncRun::STATUS_SKIPPED) { $reason = (string) (($run->error_codes ?? [])[0] ?? 'skipped'); - foreach ($policyTypes as $policyType) { - $bulkOperationService->recordSkippedWithReason($bulkRun, (string) $policyType, $reason); + if ($this->operationRun) { + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + summaryCounts: [ + 'total' => count($policyTypes), + 'processed' => count($policyTypes), + 'succeeded' => 0, + 'failed' => 0, + 'skipped' => count($policyTypes), + ], + failures: [ + ['code' => 'inventory.skipped', 'message' => $reason], + ], + ); } - $bulkOperationService->complete($bulkRun); - - $updateOpRun('failed', [], [['code' => 'SKIPPED', 'message' => $reason]]); $auditLogger->log( tenant: $tenant, @@ -228,7 +232,6 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera context: [ 'metadata' => [ 'inventory_sync_run_id' => $run->id, - 'bulk_run_id' => $bulkRun->id, 'selection_hash' => $run->selection_hash, 'reason' => $reason, ], @@ -258,21 +261,30 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera $reason = (string) (($run->error_codes ?? [])[0] ?? 'failed'); $missingPolicyTypes = array_values(array_diff($policyTypes, array_unique($processedPolicyTypes))); - foreach ($missingPolicyTypes as $policyType) { - $bulkOperationService->recordFailure($bulkRun, (string) $policyType, $reason); + + if ($this->operationRun) { + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + summaryCounts: [ + 'total' => count($policyTypes), + 'processed' => count($policyTypes), + 'succeeded' => $successCount, + 'failed' => max($failedCount, count($missingPolicyTypes)), + ], + failures: [ + ['code' => 'inventory.failed', 'message' => $reason], + ], + ); } - $bulkOperationService->complete($bulkRun); - - $updateOpRun('failed', [], [['code' => 'FAILED', 'message' => $reason]]); - $auditLogger->log( tenant: $tenant, action: 'inventory.sync.failed', context: [ 'metadata' => [ 'inventory_sync_run_id' => $run->id, - 'bulk_run_id' => $bulkRun->id, 'selection_hash' => $run->selection_hash, 'reason' => $reason, ], diff --git a/app/Jobs/SyncPoliciesJob.php b/app/Jobs/SyncPoliciesJob.php index 613b719..3344114 100644 --- a/app/Jobs/SyncPoliciesJob.php +++ b/app/Jobs/SyncPoliciesJob.php @@ -6,9 +6,10 @@ use App\Models\OperationRun; use App\Models\Policy; use App\Models\Tenant; -use App\Services\BulkOperationService; use App\Services\Intune\PolicySyncService; use App\Services\OperationRunService; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -39,7 +40,7 @@ public function middleware(): array return [new TrackOperationRun]; } - public function handle(PolicySyncService $service, BulkOperationService $bulkOperationService): void + public function handle(PolicySyncService $service, OperationRunService $operationRunService): void { $tenant = Tenant::findOrFail($this->tenantId); @@ -63,7 +64,7 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe if (! $policy) { $failureSummary[] = [ 'code' => 'policy.not_found', - 'message' => $bulkOperationService->sanitizeFailureReason("Policy {$policyId} not found"), + 'message' => "Policy {$policyId} not found", ]; continue; @@ -82,32 +83,31 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe } catch (\Throwable $e) { $failureSummary[] = [ 'code' => 'policy.sync_failed', - 'message' => $bulkOperationService->sanitizeFailureReason($e->getMessage()), + 'message' => $e->getMessage(), ]; } } $failureCount = count($failureSummary); $outcome = match (true) { - $failureCount === 0 => 'succeeded', - $syncedCount > 0 => 'partially_succeeded', - default => 'failed', + $failureCount === 0 => OperationRunOutcome::Succeeded->value, + $syncedCount > 0 => OperationRunOutcome::PartiallySucceeded->value, + default => OperationRunOutcome::Failed->value, }; if ($this->operationRun) { - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opService->updateRun( + $operationRunService->updateRun( $this->operationRun, - 'completed', - $outcome, - [ - 'policies_total' => $ids->count(), - 'policies_synced' => $syncedCount, - 'policies_skipped' => $skippedCount, - 'policies_failed' => $failureCount, + status: OperationRunStatus::Completed->value, + outcome: $outcome, + summaryCounts: [ + 'total' => $ids->count(), + 'processed' => $ids->count(), + 'succeeded' => $syncedCount, + 'failed' => $failureCount, + 'skipped' => $skippedCount, ], - $failureSummary + failures: $failureSummary, ); } @@ -126,9 +126,9 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe $failureCount = count($failures); $outcome = match (true) { - $failureCount === 0 => 'succeeded', - $syncedCount > 0 => 'partially_succeeded', - default => 'failed', + $failureCount === 0 => OperationRunOutcome::Succeeded->value, + $syncedCount > 0 => OperationRunOutcome::PartiallySucceeded->value, + default => OperationRunOutcome::Failed->value, }; $failureSummary = []; @@ -157,23 +157,24 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe $failureSummary[] = [ 'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR', - 'message' => $bulkOperationService->sanitizeFailureReason($message), + 'message' => $message, ]; } if ($this->operationRun) { - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opService->updateRun( + $total = $syncedCount + $failureCount; + + $operationRunService->updateRun( $this->operationRun, - 'completed', - $outcome, - [ - 'policy_types_total' => count($supported), - 'policies_synced' => $syncedCount, - 'policy_types_failed' => $failureCount, + status: OperationRunStatus::Completed->value, + outcome: $outcome, + summaryCounts: [ + 'total' => $total, + 'processed' => $total, + 'succeeded' => $syncedCount, + 'failed' => $failureCount, ], - $failureSummary + failures: $failureSummary, ); } } diff --git a/app/Listeners/SyncRestoreRunToOperationRun.php b/app/Listeners/SyncRestoreRunToOperationRun.php index d804673..5ddd78e 100644 --- a/app/Listeners/SyncRestoreRunToOperationRun.php +++ b/app/Listeners/SyncRestoreRunToOperationRun.php @@ -51,11 +51,14 @@ public function handle(RestoreRun $restoreRun): void [$opStatus, $opOutcome, $failures] = $this->mapStatus($status); - $summaryCounts = [ - 'assignments_success' => $restoreRun->getSuccessfulAssignmentsCount(), - 'assignments_failed' => $restoreRun->getFailedAssignmentsCount(), - 'assignments_skipped' => $restoreRun->getSkippedAssignmentsCount(), - ]; + $summaryCounts = []; + $metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : []; + + foreach (['total', 'succeeded', 'failed', 'skipped'] as $key) { + if (array_key_exists($key, $metadata) && is_numeric($metadata[$key])) { + $summaryCounts[$key] = (int) $metadata[$key]; + } + } $this->service->updateRun( $opRun, @@ -72,7 +75,7 @@ public function handle(RestoreRun $restoreRun): void protected function mapStatus(RestoreRunStatus $status): array { return match ($status) { - RestoreRunStatus::Previewed => ['queued', 'pending', []], + RestoreRunStatus::Previewed => ['completed', 'succeeded', []], RestoreRunStatus::Pending => ['queued', 'pending', []], RestoreRunStatus::Queued => ['queued', 'pending', []], RestoreRunStatus::Running => ['running', 'pending', []], diff --git a/app/Livewire/BackupSetPolicyPickerTable.php b/app/Livewire/BackupSetPolicyPickerTable.php index 492cbe2..bece723 100644 --- a/app/Livewire/BackupSetPolicyPickerTable.php +++ b/app/Livewire/BackupSetPolicyPickerTable.php @@ -4,16 +4,14 @@ use App\Jobs\AddPoliciesToBackupSetJob; use App\Models\BackupSet; -use App\Models\BulkOperationRun; use App\Models\Policy; use App\Models\Tenant; use App\Models\User; -use App\Services\BulkOperationService; use App\Services\OperationRunService; +use App\Services\Operations\BulkSelectionIdentity; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; -use App\Support\RunIdempotency; use Filament\Actions\BulkAction; use Filament\Notifications\Notification; use Filament\Tables\Columns\TextColumn; @@ -23,7 +21,6 @@ use Filament\Tables\TableComponent; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\QueryException; use Illuminate\Support\Collection; use Illuminate\Support\Str; @@ -203,7 +200,7 @@ public function table(Table $table): Table ->where('tenant_id', $tenant->getKey()) ->exists(); }) - ->action(function (Collection $records, BulkOperationService $bulkOperationService): void { + ->action(function (Collection $records): void { $backupSet = BackupSet::query()->findOrFail($this->backupSetId); $tenant = null; @@ -269,131 +266,57 @@ public function table(Table $table): Table sort($policyIds); - $idempotencyKey = RunIdempotency::buildKey( - tenantId: (int) $tenant->getKey(), - operationType: 'backup_set.add_policies', - targetId: (string) $backupSet->getKey(), - context: [ - 'policy_ids' => $policyIds, - 'include_assignments' => (bool) $this->include_assignments, - 'include_scope_tags' => (bool) $this->include_scope_tags, - 'include_foundations' => (bool) $this->include_foundations, - ], - ); + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + $selectionIdentity = $selection->fromIds($policyIds); - // --- Phase 3: Canonical Operation Run Start --- /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( + $opRun = $opService->enqueueBulkOperation( tenant: $tenant, type: 'backup_set.add_policies', - inputs: [ - 'backup_set_id' => $backupSet->id, - 'policy_ids' => $policyIds, - 'options' => [ - 'include_assignments' => (bool) $this->include_assignments, - 'include_scope_tags' => (bool) $this->include_scope_tags, - 'include_foundations' => (bool) $this->include_foundations, - ], + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), ], - initiator: $user - ); + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $user, $backupSet, $policyIds): void { + $fingerprint = (string) data_get($operationRun?->context ?? [], 'idempotency.fingerprint', ''); - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { - Notification::make() - ->title('Add policies already queued') - ->body('A matching run is already queued or running. Open the run to monitor progress.') - ->actions([ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->info() - ->send(); - - return; - } - // ---------------------------------------------- - - $existingRun = RunIdempotency::findActiveBulkOperationRun( - tenantId: (int) $tenant->getKey(), - idempotencyKey: $idempotencyKey, - ); - - if ($existingRun instanceof BulkOperationRun) { - Notification::make() - ->title('Add policies already queued') - ->body('A matching run is already queued or running. Open the run to monitor progress.') - ->actions([ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->info() - ->send(); - - return; - } - - $selectionPayload = [ - 'backup_set_id' => (int) $backupSet->getKey(), - 'policy_ids' => $policyIds, - 'options' => [ - 'include_assignments' => (bool) $this->include_assignments, - 'include_scope_tags' => (bool) $this->include_scope_tags, - 'include_foundations' => (bool) $this->include_foundations, - ], - ]; - - try { - $run = $bulkOperationService->createRun( - tenant: $tenant, - user: $user, - resource: 'backup_set', - action: 'add_policies', - itemIds: $selectionPayload, - totalItems: count($policyIds), - idempotencyKey: $idempotencyKey, - ); - } catch (QueryException $exception) { - if ((string) $exception->getCode() === '23505') { - $existingRun = RunIdempotency::findActiveBulkOperationRun( + AddPoliciesToBackupSetJob::dispatch( tenantId: (int) $tenant->getKey(), - idempotencyKey: $idempotencyKey, + userId: (int) $user->getKey(), + backupSetId: (int) $backupSet->getKey(), + policyIds: $policyIds, + options: [ + 'include_assignments' => (bool) $this->include_assignments, + 'include_scope_tags' => (bool) $this->include_scope_tags, + 'include_foundations' => (bool) $this->include_foundations, + ], + idempotencyKey: $fingerprint, + operationRun: $operationRun, ); + }, + initiator: $user, + extraContext: [ + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_count' => count($policyIds), + ], + ); - if ($existingRun instanceof BulkOperationRun) { - Notification::make() - ->title('Add policies already queued') - ->body('A matching run is already queued or running. Open the run to monitor progress.') - ->actions([ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->info() - ->send(); + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Add policies already queued') + ->body('A matching run is already queued or running. Open the run to monitor progress.') + ->actions([ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->info() + ->send(); - return; - } - } - - throw $exception; + return; } - - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opService->dispatchOrFail($opRun, function () use ($run, $backupSet, $opRun): void { - AddPoliciesToBackupSetJob::dispatch( - bulkRunId: (int) $run->getKey(), - backupSetId: (int) $backupSet->getKey(), - includeAssignments: (bool) $this->include_assignments, - includeScopeTags: (bool) $this->include_scope_tags, - includeFoundations: (bool) $this->include_foundations, - operationRun: $opRun - ); - }); - OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ \Filament\Actions\Action::make('view_run') diff --git a/app/Models/BulkOperationRun.php b/app/Models/BulkOperationRun.php deleted file mode 100644 index 5e5d135..0000000 --- a/app/Models/BulkOperationRun.php +++ /dev/null @@ -1,104 +0,0 @@ - 'array', - 'failures' => 'array', - 'processed_items' => 'integer', - 'total_items' => 'integer', - 'succeeded' => 'integer', - 'failed' => 'integer', - 'skipped' => 'integer', - ]; - - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - public function auditLog(): BelongsTo - { - return $this->belongsTo(AuditLog::class); - } - - public function runType(): string - { - return "{$this->resource}.{$this->action}"; - } - - public function statusBucket(): string - { - $status = $this->status; - - if ($status === 'pending') { - return 'queued'; - } - - if ($status === 'running') { - return 'running'; - } - - $succeededCount = (int) ($this->succeeded ?? 0); - $failedCount = (int) ($this->failed ?? 0); - $failureEntries = $this->failures ?? []; - $hasNonSkippedFailure = false; - - foreach ($failureEntries as $entry) { - if (! is_array($entry)) { - continue; - } - - if (($entry['type'] ?? 'failed') !== 'skipped') { - $hasNonSkippedFailure = true; - break; - } - } - - $hasFailures = $failedCount > 0 || $hasNonSkippedFailure; - - if ($succeededCount > 0 && $hasFailures) { - return 'partially succeeded'; - } - - if ($succeededCount === 0 && $hasFailures) { - return 'failed'; - } - - return match ($status) { - 'completed', 'completed_with_errors' => 'succeeded', - 'failed', 'aborted' => 'failed', - default => 'failed', - }; - } -} diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index 19966ea..bf741c9 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -121,7 +121,7 @@ public function getAssignmentRestoreOutcomes(): array return collect($results) ->pluck('assignment_outcomes') ->flatten(1) - ->filter() + ->filter(static fn (mixed $outcome): bool => is_array($outcome)) ->values() ->all(); } @@ -130,7 +130,7 @@ public function getSuccessfulAssignmentsCount(): int { return count(array_filter( $this->getAssignmentRestoreOutcomes(), - fn ($outcome) => $outcome['status'] === 'success' + static fn (mixed $outcome): bool => is_array($outcome) && ($outcome['status'] ?? null) === 'success' )); } @@ -138,7 +138,7 @@ public function getFailedAssignmentsCount(): int { return count(array_filter( $this->getAssignmentRestoreOutcomes(), - fn ($outcome) => $outcome['status'] === 'failed' + static fn (mixed $outcome): bool => is_array($outcome) && ($outcome['status'] ?? null) === 'failed' )); } @@ -146,7 +146,7 @@ public function getSkippedAssignmentsCount(): int { return count(array_filter( $this->getAssignmentRestoreOutcomes(), - fn ($outcome) => $outcome['status'] === 'skipped' + static fn (mixed $outcome): bool => is_array($outcome) && ($outcome['status'] ?? null) === 'skipped' )); } } diff --git a/app/Notifications/RunStatusChangedNotification.php b/app/Notifications/RunStatusChangedNotification.php index 37ae65b..696d8d0 100644 --- a/app/Notifications/RunStatusChangedNotification.php +++ b/app/Notifications/RunStatusChangedNotification.php @@ -2,10 +2,10 @@ namespace App\Notifications; -use App\Filament\Resources\BulkOperationRunResource; use App\Filament\Resources\EntraGroupSyncRunResource; use App\Filament\Resources\RestoreRunResource; use App\Models\Tenant; +use App\Support\OperationRunLinks; use Filament\Actions\Action; use Illuminate\Notifications\Notification; @@ -66,7 +66,7 @@ public function toDatabase(object $notifiable): array if ($tenant) { $url = match ($runType) { - 'bulk_operation' => BulkOperationRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), + 'bulk_operation' => OperationRunLinks::view($runId, $tenant), 'restore' => RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), 'directory_groups' => EntraGroupSyncRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), default => null, diff --git a/app/Policies/BulkOperationRunPolicy.php b/app/Policies/BulkOperationRunPolicy.php deleted file mode 100644 index 6ee6a87..0000000 --- a/app/Policies/BulkOperationRunPolicy.php +++ /dev/null @@ -1,39 +0,0 @@ -canAccessTenant($tenant); - } - - public function view(User $user, BulkOperationRun $run): bool - { - $tenant = Tenant::current(); - - if (! $tenant) { - return false; - } - - if (! $user->canAccessTenant($tenant)) { - return false; - } - - return (int) $run->tenant_id === (int) $tenant->getKey(); - } -} diff --git a/app/Policies/OperationRunPolicy.php b/app/Policies/OperationRunPolicy.php index 0e1e400..aa2c3ae 100644 --- a/app/Policies/OperationRunPolicy.php +++ b/app/Policies/OperationRunPolicy.php @@ -6,6 +6,7 @@ use App\Models\Tenant; use App\Models\User; use Illuminate\Auth\Access\HandlesAuthorization; +use Illuminate\Auth\Access\Response; class OperationRunPolicy { @@ -22,7 +23,7 @@ public function viewAny(User $user): bool return $user->canAccessTenant($tenant); } - public function view(User $user, OperationRun $run): bool + public function view(User $user, OperationRun $run): Response|bool { $tenant = Tenant::current(); @@ -34,6 +35,10 @@ public function view(User $user, OperationRun $run): bool return false; } - return (int) $run->tenant_id === (int) $tenant->getKey(); + if ((int) $run->tenant_id !== (int) $tenant->getKey()) { + return Response::denyAsNotFound(); + } + + return true; } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5a7cae0..d663e0d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,7 +3,6 @@ namespace App\Providers; use App\Models\BackupSchedule; -use App\Models\BulkOperationRun; use App\Models\EntraGroup; use App\Models\EntraGroupSyncRun; use App\Models\Finding; @@ -14,7 +13,6 @@ use App\Models\UserTenantPreference; use App\Observers\RestoreRunObserver; use App\Policies\BackupSchedulePolicy; -use App\Policies\BulkOperationRunPolicy; use App\Policies\EntraGroupPolicy; use App\Policies\EntraGroupSyncRunPolicy; use App\Policies\FindingPolicy; @@ -121,7 +119,6 @@ public function boot(): void }); Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class); - Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class); Gate::policy(Finding::class, FindingPolicy::class); Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class); Gate::policy(EntraGroup::class, EntraGroupPolicy::class); diff --git a/app/Services/AdapterRunReconciler.php b/app/Services/AdapterRunReconciler.php new file mode 100644 index 0000000..70cae60 --- /dev/null +++ b/app/Services/AdapterRunReconciler.php @@ -0,0 +1,273 @@ + + */ + public function supportedTypes(): array + { + return [ + 'restore.execute', + ]; + } + + /** + * @param array{type?: string|null, tenant_id?: int|null, older_than_minutes?: int, limit?: int, dry_run?: bool} $options + * @return array{candidates:int,reconciled:int,skipped:int,changes:array>} + */ + public function reconcile(array $options = []): array + { + $type = $options['type'] ?? null; + $tenantId = $options['tenant_id'] ?? null; + $olderThanMinutes = max(1, (int) ($options['older_than_minutes'] ?? 10)); + $limit = max(1, (int) ($options['limit'] ?? 50)); + $dryRun = (bool) ($options['dry_run'] ?? true); + + if ($type !== null && ! in_array($type, $this->supportedTypes(), true)) { + throw new \InvalidArgumentException('Unsupported adapter run type: '.$type); + } + + $cutoff = CarbonImmutable::now()->subMinutes($olderThanMinutes); + + $query = OperationRun::query() + ->whereIn('type', $type ? [$type] : $this->supportedTypes()) + ->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value]) + ->whereNotNull('context->restore_run_id') + ->where(function (Builder $q) use ($cutoff): void { + $q + ->where(function (Builder $q2) use ($cutoff): void { + $q2->whereNull('started_at')->where('created_at', '<', $cutoff); + }) + ->orWhere(function (Builder $q2) use ($cutoff): void { + $q2->whereNotNull('started_at')->where('started_at', '<', $cutoff); + }); + }) + ->orderBy('id') + ->limit($limit); + + if (is_int($tenantId) && $tenantId > 0) { + $query->where('tenant_id', $tenantId); + } + + $candidates = $query->get(); + + $changes = []; + $reconciled = 0; + $skipped = 0; + + foreach ($candidates as $run) { + $change = $this->reconcileOne($run, $dryRun); + + if ($change === null) { + $skipped++; + + continue; + } + + $changes[] = $change; + + if (($change['applied'] ?? false) === true) { + $reconciled++; + } + } + + return [ + 'candidates' => $candidates->count(), + 'reconciled' => $reconciled, + 'skipped' => $skipped, + 'changes' => $changes, + ]; + } + + /** + * @return array|null + */ + private function reconcileOne(OperationRun $run, bool $dryRun): ?array + { + if ($run->type !== 'restore.execute') { + return null; + } + + $context = is_array($run->context) ? $run->context : []; + $restoreRunId = $context['restore_run_id'] ?? null; + + if (! is_numeric($restoreRunId)) { + return null; + } + + $restoreRun = RestoreRun::query() + ->where('tenant_id', $run->tenant_id) + ->whereKey((int) $restoreRunId) + ->first(); + + if (! $restoreRun instanceof RestoreRun) { + return null; + } + + $restoreStatus = RestoreRunStatus::fromString($restoreRun->status); + + if (! $this->isTerminalRestoreStatus($restoreStatus)) { + return null; + } + + [$opStatus, $opOutcome, $failures] = $this->mapRestoreToOperationRun($restoreRun, $restoreStatus); + + $summaryCounts = $this->buildSummaryCounts($restoreRun); + + $before = [ + 'status' => (string) $run->status, + 'outcome' => (string) $run->outcome, + ]; + + $after = [ + 'status' => $opStatus, + 'outcome' => $opOutcome, + ]; + + if ($dryRun) { + return [ + 'applied' => false, + 'operation_run_id' => (int) $run->getKey(), + 'type' => (string) $run->type, + 'restore_run_id' => (int) $restoreRun->getKey(), + 'before' => $before, + 'after' => $after, + ]; + } + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $runs->updateRun( + $run, + status: $opStatus, + outcome: $opOutcome, + summaryCounts: $summaryCounts, + failures: $failures, + ); + + $run->refresh(); + + $updatedContext = is_array($run->context) ? $run->context : []; + $reconciliation = is_array($updatedContext['reconciliation'] ?? null) ? $updatedContext['reconciliation'] : []; + $reconciliation['reconciled_at'] = CarbonImmutable::now()->toIso8601String(); + $reconciliation['reason'] = 'adapter_out_of_sync'; + + $updatedContext['reconciliation'] = $reconciliation; + + $run->context = $updatedContext; + + if ($run->started_at === null && $restoreRun->started_at !== null) { + $run->started_at = $restoreRun->started_at; + } + + if ($run->completed_at === null && $restoreRun->completed_at !== null) { + $run->completed_at = $restoreRun->completed_at; + } + + $run->save(); + + return [ + 'applied' => true, + 'operation_run_id' => (int) $run->getKey(), + 'type' => (string) $run->type, + 'restore_run_id' => (int) $restoreRun->getKey(), + 'before' => $before, + 'after' => $after, + ]; + } + + private function isTerminalRestoreStatus(?RestoreRunStatus $status): bool + { + if (! $status instanceof RestoreRunStatus) { + return false; + } + + return in_array($status, [ + RestoreRunStatus::Previewed, + RestoreRunStatus::Completed, + RestoreRunStatus::Partial, + RestoreRunStatus::Failed, + RestoreRunStatus::Cancelled, + RestoreRunStatus::Aborted, + RestoreRunStatus::CompletedWithErrors, + ], true); + } + + /** + * @return array{0:string,1:string,2:array} + */ + private function mapRestoreToOperationRun(RestoreRun $restoreRun, RestoreRunStatus $status): array + { + $failureReason = is_string($restoreRun->failure_reason ?? null) ? (string) $restoreRun->failure_reason : ''; + + return match ($status) { + RestoreRunStatus::Previewed => [OperationRunStatus::Completed->value, OperationRunOutcome::Succeeded->value, []], + RestoreRunStatus::Completed => [OperationRunStatus::Completed->value, OperationRunOutcome::Succeeded->value, []], + RestoreRunStatus::Partial, RestoreRunStatus::CompletedWithErrors => [ + OperationRunStatus::Completed->value, + OperationRunOutcome::PartiallySucceeded->value, + [[ + 'code' => 'restore.completed_with_warnings', + 'message' => $failureReason !== '' ? $failureReason : 'Restore completed with warnings.', + ]], + ], + RestoreRunStatus::Failed, RestoreRunStatus::Aborted => [ + OperationRunStatus::Completed->value, + OperationRunOutcome::Failed->value, + [[ + 'code' => 'restore.failed', + 'message' => $failureReason !== '' ? $failureReason : 'Restore failed.', + ]], + ], + RestoreRunStatus::Cancelled => [ + OperationRunStatus::Completed->value, + OperationRunOutcome::Failed->value, + [[ + 'code' => 'restore.cancelled', + 'message' => 'Restore run was cancelled.', + ]], + ], + default => [OperationRunStatus::Running->value, OperationRunOutcome::Pending->value, []], + }; + } + + /** + * @return array + */ + private function buildSummaryCounts(RestoreRun $restoreRun): array + { + $metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : []; + + $counts = []; + + foreach (['total', 'processed', 'succeeded', 'failed', 'skipped'] as $key) { + if (array_key_exists($key, $metadata) && is_numeric($metadata[$key])) { + $counts[$key] = (int) $metadata[$key]; + } + } + + if (! isset($counts['processed'])) { + $processed = (int) ($counts['succeeded'] ?? 0) + (int) ($counts['failed'] ?? 0) + (int) ($counts['skipped'] ?? 0); + + if ($processed > 0) { + $counts['processed'] = $processed; + } + } + + return $counts; + } +} diff --git a/app/Services/BulkOperationService.php b/app/Services/BulkOperationService.php deleted file mode 100644 index 95f9804..0000000 --- a/app/Services/BulkOperationService.php +++ /dev/null @@ -1,269 +0,0 @@ - $tenant->id, - 'user_id' => $user->id, - 'resource' => $resource, - 'action' => $action, - 'idempotency_key' => $idempotencyKey, - 'status' => 'pending', - 'item_ids' => $itemIds, - 'total_items' => $effectiveTotalItems, - 'processed_items' => 0, - 'succeeded' => 0, - 'failed' => 0, - 'skipped' => 0, - 'failures' => [], - ]); - - $auditLog = $this->auditLogger->log( - tenant: $tenant, - action: "bulk.{$resource}.{$action}.created", - context: [ - 'metadata' => [ - 'bulk_run_id' => $run->id, - 'total_items' => $effectiveTotalItems, - ], - ], - actorId: $user->id, - actorEmail: $user->email, - actorName: $user->name, - resourceType: 'bulk_operation_run', - resourceId: (string) $run->id - ); - - $run->update(['audit_log_id' => $auditLog->id]); - - return $run; - } - - public function start(BulkOperationRun $run): void - { - $run->update(['status' => 'running']); - } - - public function recordSuccess(BulkOperationRun $run): void - { - $run->increment('processed_items'); - $run->increment('succeeded'); - } - - public function recordFailure(BulkOperationRun $run, string $itemId, string $reason, ?string $reasonCode = null): void - { - $reason = $this->sanitizeFailureReason($reason); - - $failures = $run->failures ?? []; - $failureEntry = [ - 'item_id' => $itemId, - 'reason' => $reason, - 'timestamp' => now()->toIso8601String(), - ]; - - if (is_string($reasonCode) && $reasonCode !== '') { - $failureEntry['reason_code'] = $reasonCode; - } - - $failures[] = $failureEntry; - - $run->update([ - 'failures' => $failures, - 'processed_items' => $run->processed_items + 1, - 'failed' => $run->failed + 1, - ]); - } - - public function recordSkipped(BulkOperationRun $run): void - { - $run->increment('processed_items'); - $run->increment('skipped'); - } - - public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, string $reason, ?string $reasonCode = null): void - { - $reason = $this->sanitizeFailureReason($reason); - - $failures = $run->failures ?? []; - $failureEntry = [ - 'item_id' => $itemId, - 'reason' => $reason, - 'type' => 'skipped', - 'timestamp' => now()->toIso8601String(), - ]; - - if (is_string($reasonCode) && $reasonCode !== '') { - $failureEntry['reason_code'] = $reasonCode; - } - - $failures[] = $failureEntry; - - $run->update([ - 'failures' => $failures, - 'processed_items' => $run->processed_items + 1, - 'skipped' => $run->skipped + 1, - ]); - } - - public function complete(BulkOperationRun $run): void - { - $run->refresh(); - - if ($run->processed_items > $run->total_items) { - BulkOperationRun::query() - ->whereKey($run->id) - ->update(['total_items' => $run->processed_items]); - - $run->refresh(); - } - - if (! in_array($run->status, ['pending', 'running'], true)) { - return; - } - - $failureEntries = collect($run->failures ?? []); - $hasFailures = $run->failed > 0 - || $failureEntries->contains(fn (array $entry): bool => ($entry['type'] ?? 'failed') !== 'skipped'); - - $status = $hasFailures ? 'completed_with_errors' : 'completed'; - - $updated = BulkOperationRun::query() - ->whereKey($run->id) - ->whereIn('status', ['pending', 'running']) - ->update(['status' => $status]); - - if ($updated === 0) { - return; - } - - $run->refresh(); - - $failureEntries = collect($run->failures ?? []); - $failedReasons = $failureEntries - ->filter(fn (array $entry) => ($entry['type'] ?? 'failed') !== 'skipped') - ->groupBy('reason') - ->map(fn ($group) => $group->count()) - ->all(); - - $skippedReasons = $failureEntries - ->filter(fn (array $entry) => ($entry['type'] ?? null) === 'skipped') - ->groupBy('reason') - ->map(fn ($group) => $group->count()) - ->all(); - - $this->auditLogger->log( - tenant: $run->tenant, - action: "bulk.{$run->resource}.{$run->action}.{$status}", - context: [ - 'metadata' => [ - 'bulk_run_id' => $run->id, - 'succeeded' => $run->succeeded, - 'failed' => $run->failed, - 'skipped' => $run->skipped, - 'failed_reasons' => $failedReasons, - 'skipped_reasons' => $skippedReasons, - ], - ], - actorId: $run->user_id, - resourceType: 'bulk_operation_run', - resourceId: (string) $run->id - ); - } - - public function fail(BulkOperationRun $run, string $reason): void - { - $run->update(['status' => 'failed']); - - $reason = $this->sanitizeFailureReason($reason); - - $this->auditLogger->log( - tenant: $run->tenant, - action: "bulk.{$run->resource}.{$run->action}.failed", - context: [ - 'reason' => $reason, - 'metadata' => [ - 'bulk_run_id' => $run->id, - ], - ], - actorId: $run->user_id, - status: 'failure', - resourceType: 'bulk_operation_run', - resourceId: (string) $run->id - ); - } - - public function abort(BulkOperationRun $run, string $reason): void - { - $run->update(['status' => 'aborted']); - - $reason = $this->sanitizeFailureReason($reason); - - $this->auditLogger->log( - tenant: $run->tenant, - action: "bulk.{$run->resource}.{$run->action}.aborted", - context: [ - 'reason' => $reason, - 'metadata' => [ - 'bulk_run_id' => $run->id, - 'succeeded' => $run->succeeded, - 'failed' => $run->failed, - 'skipped' => $run->skipped, - ], - ], - actorId: $run->user_id, - status: 'failure', - resourceType: 'bulk_operation_run', - resourceId: (string) $run->id - ); - } -} diff --git a/app/Services/Graph/MicrosoftGraphClient.php b/app/Services/Graph/MicrosoftGraphClient.php index 094fe16..66f9238 100644 --- a/app/Services/Graph/MicrosoftGraphClient.php +++ b/app/Services/Graph/MicrosoftGraphClient.php @@ -90,6 +90,29 @@ public function listPolicies(string $policyType, array $options = []): GraphResp $response = $this->send('GET', $endpoint, $sendOptions, $context); + // Some tenants intermittently return OData select parsing errors. + // Retry once with the same $select before applying the compatibility fallback. + if ($response->failed()) { + $graphResponse = $this->toGraphResponse( + action: 'list_policies', + response: $response, + transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), + meta: [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fullPath, + 'method' => 'GET', + 'query' => $query ?: null, + 'client_request_id' => $clientRequestId, + ], + warnings: $warnings, + ); + + if ($this->shouldApplySelectFallback($graphResponse, $query)) { + $response = $this->send('GET', $endpoint, $sendOptions, $context); + } + } + if ($response->failed()) { $graphResponse = $this->toGraphResponse( action: 'list_policies', @@ -626,7 +649,24 @@ private function send(string $method, string $path, array $options = [], array $ $pending = Http::baseUrl($this->baseUrl) ->acceptJson() ->timeout($this->timeout) - ->retry($this->retryTimes, $this->retrySleepMs) + ->retry( + $this->retryTimes, + fn (int $attempt, Throwable $exception): int => $this->retryDelayMs($attempt), + function (Throwable $exception): bool { + if ($exception instanceof ConnectionException) { + return true; + } + + if ($exception instanceof RequestException) { + $status = $exception->response?->status(); + + return in_array($status, [429, 503], true); + } + + return false; + }, + throw: true, + ) ->withToken($token) ->withHeaders([ 'client-request-id' => $clientRequestId, @@ -667,6 +707,23 @@ private function send(string $method, string $path, array $options = [], array $ return $response; } + private function retryDelayMs(int $attempt): int + { + $baseMs = max(0, $this->retrySleepMs); + + if ($attempt <= 1 || $baseMs === 0) { + return $baseMs; + } + + $exponential = $baseMs * (2 ** ($attempt - 1)); + $capped = min($exponential, 5000); + + $jitterMax = (int) floor($capped * 0.2); + $jitter = $jitterMax > 0 ? random_int(0, $jitterMax) : 0; + + return $capped + $jitter; + } + private function toGraphResponse(string $action, Response $response, callable $transform, array $meta = [], array $warnings = []): GraphResponse { $json = $response->json() ?? []; diff --git a/app/Services/OperationRunService.php b/app/Services/OperationRunService.php index db85eb8..7df994a 100644 --- a/app/Services/OperationRunService.php +++ b/app/Services/OperationRunService.php @@ -7,11 +7,17 @@ use App\Models\User; use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification; use App\Notifications\OperationRunQueued as OperationRunQueuedNotification; +use App\Services\Operations\BulkIdempotencyFingerprint; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\OpsUx\BulkRunContext; +use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OpsUx\SummaryCountsNormalizer; use Illuminate\Database\QueryException; +use Illuminate\Support\Facades\DB; use InvalidArgumentException; +use ReflectionFunction; +use ReflectionMethod; use Throwable; class OperationRunService @@ -71,6 +77,124 @@ public function ensureRun( } } + public function ensureRunWithIdentity( + Tenant $tenant, + string $type, + array $identityInputs, + array $context, + ?User $initiator = null + ): OperationRun { + $hash = $this->calculateHash($tenant->id, $type, $identityInputs); + + // Idempotency Check (Fast Path) + // We check specific status to match the partial unique index + $existing = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('run_identity_hash', $hash) + ->whereIn('status', OperationRunStatus::values()) + ->where('status', '!=', OperationRunStatus::Completed->value) + ->first(); + + if ($existing) { + return $existing; + } + + // Create new run (race-safe via partial unique index) + try { + return OperationRun::create([ + 'tenant_id' => $tenant->id, + 'user_id' => $initiator?->id, + 'initiator_name' => $initiator?->name ?? 'System', + 'type' => $type, + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'run_identity_hash' => $hash, + 'context' => $context, + ]); + } catch (QueryException $e) { + // Unique violation (active-run dedupe): + // - PostgreSQL: 23505 + // - SQLite (tests): 23000 (generic integrity violation; message indicates UNIQUE constraint failed) + if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) { + throw $e; + } + + $existing = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('run_identity_hash', $hash) + ->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value]) + ->first(); + + if ($existing) { + return $existing; + } + + throw $e; + } + } + + /** + * Standardized enqueue helper for bulk operations. + * + * Builds the canonical bulk context contract and ensures active-run dedupe + * is based on target scope + selection identity. + * + * @param array{entra_tenant_id?: mixed, directory_context_id?: mixed} $targetScope + * @param array{kind: string, ids_hash?: string, query_hash?: string} $selectionIdentity + * @param array $extraContext + */ + public function enqueueBulkOperation( + Tenant $tenant, + string $type, + array $targetScope, + array $selectionIdentity, + callable $dispatcher, + ?User $initiator = null, + array $extraContext = [], + bool $emitQueuedNotification = true + ): OperationRun { + $targetScope = BulkRunContext::normalizeTargetScope($targetScope); + + $entraTenantId = $targetScope['entra_tenant_id'] ?? null; + + if (is_string($entraTenantId) && $entraTenantId !== '' && $entraTenantId !== (string) $tenant->graphTenantId()) { + throw new InvalidArgumentException('Bulk enqueue target_scope entra_tenant_id must match the current tenant.'); + } + + /** @var BulkIdempotencyFingerprint $fingerprints */ + $fingerprints = app(BulkIdempotencyFingerprint::class); + + $fingerprint = $fingerprints->build($type, $targetScope, $selectionIdentity); + + $context = array_merge($extraContext, [ + 'operation' => [ + 'type' => $type, + ], + 'target_scope' => $targetScope, + 'selection' => $selectionIdentity, + 'idempotency' => [ + 'fingerprint' => $fingerprint, + ], + ]); + + $run = $this->ensureRunWithIdentity( + tenant: $tenant, + type: $type, + identityInputs: [ + 'target_scope' => $targetScope, + 'selection' => $selectionIdentity, + ], + context: $context, + initiator: $initiator, + ); + + if ($run->wasRecentlyCreated) { + $this->dispatchOrFail($run, $dispatcher, emitQueuedNotification: $emitQueuedNotification); + } + + return $run; + } + public function updateRun( OperationRun $run, string $status, @@ -137,6 +261,51 @@ public function updateRun( return $run; } + /** + * Increment whitelisted summary_counts keys for a run. + * + * Uses a transaction + row lock to prevent lost updates when multiple workers + * update counts concurrently. + * + * @param array $delta + */ + public function incrementSummaryCounts(OperationRun $run, array $delta): OperationRun + { + $delta = $this->sanitizeSummaryCounts($delta); + + if ($delta === []) { + return $run; + } + + /** @var OperationRun $updated */ + $updated = DB::transaction(function () use ($run, $delta): OperationRun { + $locked = OperationRun::query() + ->whereKey($run->getKey()) + ->lockForUpdate() + ->first(); + + if (! $locked instanceof OperationRun) { + return $run; + } + + $current = is_array($locked->summary_counts ?? null) ? $locked->summary_counts : []; + $current = SummaryCountsNormalizer::normalize($current); + + foreach ($delta as $key => $value) { + $current[$key] = ($current[$key] ?? 0) + $value; + } + + $locked->summary_counts = $current; + $locked->save(); + + return $locked; + }); + + $updated->refresh(); + + return $updated; + } + /** * Dispatch a queued operation safely. * @@ -146,7 +315,7 @@ public function updateRun( public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = true): void { try { - $dispatcher(); + $this->invokeDispatcher($dispatcher, $run); if ($emitQueuedNotification && $run->wasRecentlyCreated && $run->user instanceof User) { $run->user->notify(new OperationRunQueuedNotification($run)); @@ -168,6 +337,107 @@ public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $em } } + /** + * Append failure entries to failure_summary (sanitized + bounded) without overwriting existing. + * + * @param array $failures + */ + public function appendFailures(OperationRun $run, array $failures): OperationRun + { + $failures = $this->sanitizeFailures($failures); + + if ($failures === []) { + return $run; + } + + /** @var OperationRun $updated */ + $updated = DB::transaction(function () use ($run, $failures): OperationRun { + $locked = OperationRun::query() + ->whereKey($run->getKey()) + ->lockForUpdate() + ->first(); + + if (! $locked instanceof OperationRun) { + return $run; + } + + $current = is_array($locked->failure_summary ?? null) ? $locked->failure_summary : []; + $current = $this->sanitizeFailures($current); + + $merged = array_merge($current, $failures); + + // Prevent runaway payloads. + $merged = array_slice($merged, 0, 50); + + $locked->failure_summary = $merged; + $locked->save(); + + return $locked; + }); + + $updated->refresh(); + + return $updated; + } + + /** + * Mark a bulk run as completed if summary_counts indicate all work is processed. + */ + public function maybeCompleteBulkRun(OperationRun $run): OperationRun + { + $run->refresh(); + + if ($run->status === OperationRunStatus::Completed->value) { + return $run; + } + + $updated = DB::transaction(function () use ($run): OperationRun { + $locked = OperationRun::query() + ->whereKey($run->getKey()) + ->lockForUpdate() + ->first(); + + if (! $locked instanceof OperationRun) { + return $run; + } + + if ($locked->status === OperationRunStatus::Completed->value) { + return $locked; + } + + $counts = is_array($locked->summary_counts ?? null) ? $locked->summary_counts : []; + $counts = SummaryCountsNormalizer::normalize($counts); + + $total = (int) ($counts['total'] ?? 0); + $processed = (int) ($counts['processed'] ?? 0); + $failed = (int) ($counts['failed'] ?? 0); + + if ($total <= 0 || $processed < $total) { + return $locked; + } + + $outcome = OperationRunOutcome::Succeeded->value; + + if ($failed > 0 && $failed < $total) { + $outcome = OperationRunOutcome::PartiallySucceeded->value; + } + + if ($failed >= $total) { + $outcome = OperationRunOutcome::Failed->value; + } + + return $this->updateRun( + $locked, + status: OperationRunStatus::Completed->value, + outcome: $outcome, + ); + }); + + $updated->refresh(); + + return $updated; + } + public function failRun(OperationRun $run, Throwable $e): OperationRun { return $this->updateRun( @@ -183,6 +453,30 @@ public function failRun(OperationRun $run, Throwable $e): OperationRun ); } + private function invokeDispatcher(callable $dispatcher, OperationRun $run): void + { + $ref = null; + + if (is_array($dispatcher) && count($dispatcher) === 2) { + $ref = new ReflectionMethod($dispatcher[0], (string) $dispatcher[1]); + } elseif (is_string($dispatcher) && str_contains($dispatcher, '::')) { + [$class, $method] = explode('::', $dispatcher, 2); + $ref = new ReflectionMethod($class, $method); + } elseif ($dispatcher instanceof \Closure) { + $ref = new ReflectionFunction($dispatcher); + } elseif (is_object($dispatcher) && method_exists($dispatcher, '__invoke')) { + $ref = new ReflectionMethod($dispatcher, '__invoke'); + } + + if ($ref && $ref->getNumberOfParameters() >= 1) { + $dispatcher($run); + + return; + } + + $dispatcher(); + } + protected function calculateHash(int $tenantId, string $type, array $inputs): string { $normalizedInputs = $this->normalizeInputs($inputs); @@ -237,7 +531,7 @@ protected function isListArray(array $array): bool /** * @param array $failures - * @return array + * @return array */ protected function sanitizeFailures(array $failures): array { @@ -245,10 +539,12 @@ protected function sanitizeFailures(array $failures): array foreach ($failures as $failure) { $code = (string) ($failure['code'] ?? 'unknown'); + $reasonCode = (string) ($failure['reason_code'] ?? $code); $message = (string) ($failure['message'] ?? ''); $sanitized[] = [ 'code' => $this->sanitizeFailureCode($code), + 'reason_code' => RunFailureSanitizer::normalizeReasonCode($reasonCode), 'message' => $this->sanitizeMessage($message), ]; } @@ -258,27 +554,12 @@ protected function sanitizeFailures(array $failures): array protected function sanitizeFailureCode(string $code): string { - $code = strtolower(trim($code)); - - if ($code === '') { - return 'unknown'; - } - - return substr($code, 0, 80); + return RunFailureSanitizer::sanitizeCode($code); } protected function sanitizeMessage(string $message): string { - $message = trim(str_replace(["\r", "\n"], ' ', $message)); - - // Redact obvious bearer tokens / secrets. - $message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', 'Bearer [REDACTED]', $message) ?? $message; - $message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\s*[:=]\s*[^\s]+/i', '$1=[REDACTED]', $message) ?? $message; - - // Redact long opaque blobs that look token-like. - $message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message; - - return substr($message, 0, 120); + return RunFailureSanitizer::sanitizeMessage($message); } /** diff --git a/app/Services/Operations/BulkIdempotencyFingerprint.php b/app/Services/Operations/BulkIdempotencyFingerprint.php new file mode 100644 index 0000000..aa2d7b2 --- /dev/null +++ b/app/Services/Operations/BulkIdempotencyFingerprint.php @@ -0,0 +1,43 @@ + $targetScope + * @param array{kind: string, ids_hash?: string, query_hash?: string} $selectionIdentity + */ + public function build(string $operationType, array $targetScope, array $selectionIdentity): string + { + $payload = [ + 'type' => trim($operationType), + 'target_scope' => $targetScope, + 'selection' => $selectionIdentity, + ]; + + $payload = $this->ksortRecursive($payload); + + $json = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return hash('sha256', $json); + } + + private function ksortRecursive(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + + $isList = array_is_list($value); + if (! $isList) { + ksort($value); + } + + foreach ($value as $key => $child) { + $value[$key] = $this->ksortRecursive($child); + } + + return $value; + } +} diff --git a/app/Services/Operations/BulkSelectionIdentity.php b/app/Services/Operations/BulkSelectionIdentity.php new file mode 100644 index 0000000..9cd480b --- /dev/null +++ b/app/Services/Operations/BulkSelectionIdentity.php @@ -0,0 +1,87 @@ + $ids + * @return array{kind: 'ids', ids_hash: string, ids_count: int} + */ + public function fromIds(array $ids): array + { + $normalized = []; + + foreach ($ids as $id) { + if (is_int($id)) { + $normalized[] = (string) $id; + + continue; + } + + if (! is_string($id)) { + continue; + } + + $id = trim($id); + if ($id === '') { + continue; + } + + $normalized[] = $id; + } + + $normalized = array_values(array_unique($normalized)); + sort($normalized); + + $json = json_encode($normalized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return [ + 'kind' => 'ids', + 'ids_hash' => hash('sha256', $json), + 'ids_count' => count($normalized), + ]; + } + + /** + * @param array $queryPayload + * @return array{kind: 'query', query_hash: string} + */ + public function fromQuery(array $queryPayload): array + { + $json = $this->canonicalJson($queryPayload); + + return [ + 'kind' => 'query', + 'query_hash' => hash('sha256', $json), + ]; + } + + /** + * @param array $payload + */ + public function canonicalJson(array $payload): string + { + $normalized = $this->ksortRecursive($payload); + + return (string) json_encode($normalized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + private function ksortRecursive(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + + $isList = array_is_list($value); + if (! $isList) { + ksort($value); + } + + foreach ($value as $key => $child) { + $value[$key] = $this->ksortRecursive($child); + } + + return $value; + } +} diff --git a/app/Services/Operations/TargetScopeConcurrencyLimiter.php b/app/Services/Operations/TargetScopeConcurrencyLimiter.php new file mode 100644 index 0000000..2d04322 --- /dev/null +++ b/app/Services/Operations/TargetScopeConcurrencyLimiter.php @@ -0,0 +1,72 @@ +scopeKey($targetScope); + + return $this->acquireSlotInternal("bulk_ops:tenant:{$tenantId}:scope:{$scopeKey}:slot:", $max); + } + + private function acquireSlotInternal(string $prefix, int $max): ?Lock + { + $ttlSeconds = (int) config('tenantpilot.bulk_operations.concurrency.lock_ttl_seconds', $this->lockTtlSeconds); + $ttlSeconds = max(1, $ttlSeconds); + + for ($slot = 0; $slot < $max; $slot++) { + $lock = Cache::lock($prefix.$slot, $ttlSeconds); + + if ($lock->get()) { + return $lock; + } + } + + return null; + } + + /** + * @param array{entra_tenant_id?: mixed, directory_context_id?: mixed} $targetScope + */ + private function scopeKey(array $targetScope): string + { + $entraTenantId = $targetScope['entra_tenant_id'] ?? null; + $directoryContextId = $targetScope['directory_context_id'] ?? null; + + if (is_string($entraTenantId) && trim($entraTenantId) !== '') { + return 'entra:'.trim($entraTenantId); + } + + if (is_string($directoryContextId) && trim($directoryContextId) !== '') { + return 'directory_context:'.trim($directoryContextId); + } + + if (is_int($directoryContextId)) { + return 'directory_context:'.$directoryContextId; + } + + throw new InvalidArgumentException('Target scope must include entra_tenant_id or directory_context_id.'); + } +} diff --git a/app/Support/OperationCatalog.php b/app/Support/OperationCatalog.php index 0b313ce..b2baffa 100644 --- a/app/Support/OperationCatalog.php +++ b/app/Support/OperationCatalog.php @@ -15,6 +15,9 @@ public static function labels(): array 'policy.sync' => 'Policy sync', 'policy.sync_one' => 'Policy sync', 'policy.capture_snapshot' => 'Policy snapshot', + 'policy.delete' => 'Delete policies', + 'policy.unignore' => 'Restore policies', + 'policy.export' => 'Export policies to backup', 'inventory.sync' => 'Inventory sync', 'directory_groups.sync' => 'Directory groups sync', 'drift.generate' => 'Drift generation', @@ -26,6 +29,10 @@ public static function labels(): array 'backup_schedule.run_now' => 'Backup schedule run', 'backup_schedule.retry' => 'Backup schedule retry', 'restore.execute' => 'Restore execution', + 'restore_run.delete' => 'Delete restore runs', + 'restore_run.restore' => 'Restore restore runs', + 'restore_run.force_delete' => 'Force delete restore runs', + 'tenant.sync' => 'Tenant sync', 'policy_version.prune' => 'Prune policy versions', 'policy_version.restore' => 'Restore policy versions', 'policy_version.force_delete' => 'Delete policy versions', @@ -47,6 +54,7 @@ public static function expectedDurationSeconds(string $operationType): ?int { return match (trim($operationType)) { 'policy.sync', 'policy.sync_one' => 90, + 'policy.export' => 120, 'inventory.sync' => 180, 'directory_groups.sync' => 120, 'drift.generate' => 240, diff --git a/app/Support/OpsUx/BulkRunContext.php b/app/Support/OpsUx/BulkRunContext.php new file mode 100644 index 0000000..7aa95f7 --- /dev/null +++ b/app/Support/OpsUx/BulkRunContext.php @@ -0,0 +1,67 @@ + $extra + * @return array + */ + public static function build( + string $operationType, + array $targetScope, + array $selectionIdentity, + string $fingerprint, + array $extra = [], + ): array { + $targetScope = self::normalizeTargetScope($targetScope); + + return array_merge($extra, [ + 'operation' => [ + 'type' => trim($operationType), + ], + 'target_scope' => $targetScope, + 'selection' => $selectionIdentity, + 'idempotency' => [ + 'fingerprint' => trim($fingerprint), + ], + ]); + } + + /** + * @param array{entra_tenant_id?: mixed, directory_context_id?: mixed} $targetScope + * @return array{entra_tenant_id?: string, directory_context_id?: string} + */ + public static function normalizeTargetScope(array $targetScope): array + { + $entraTenantId = $targetScope['entra_tenant_id'] ?? null; + $directoryContextId = $targetScope['directory_context_id'] ?? null; + + $normalized = []; + + if (is_string($entraTenantId) && trim($entraTenantId) !== '') { + $normalized['entra_tenant_id'] = trim($entraTenantId); + } + + if (is_string($directoryContextId) && trim($directoryContextId) !== '') { + $normalized['directory_context_id'] = trim($directoryContextId); + } + + if (is_int($directoryContextId)) { + $normalized['directory_context_id'] = (string) $directoryContextId; + } + + if (! isset($normalized['entra_tenant_id']) && ! isset($normalized['directory_context_id'])) { + throw new InvalidArgumentException('Target scope must include entra_tenant_id or directory_context_id.'); + } + + return $normalized; + } +} diff --git a/app/Support/OpsUx/RunFailureSanitizer.php b/app/Support/OpsUx/RunFailureSanitizer.php new file mode 100644 index 0000000..cba491e --- /dev/null +++ b/app/Support/OpsUx/RunFailureSanitizer.php @@ -0,0 +1,103 @@ + self::REASON_PERMISSION_DENIED, + 'graph_transient' => self::REASON_GRAPH_TIMEOUT, + 'unknown' => self::REASON_UNKNOWN_ERROR, + default => $candidate, + }; + + if (in_array($candidate, $allowed, true)) { + return $candidate; + } + + // Heuristic normalization for ad-hoc codes used across jobs/services. + if (str_contains($candidate, 'throttle') || str_contains($candidate, '429')) { + return self::REASON_GRAPH_THROTTLED; + } + + if (str_contains($candidate, 'timeout') || str_contains($candidate, 'transient') || str_contains($candidate, '503') || str_contains($candidate, '504')) { + return self::REASON_GRAPH_TIMEOUT; + } + + if (str_contains($candidate, 'forbidden') || str_contains($candidate, 'permission') || str_contains($candidate, 'unauthorized') || str_contains($candidate, '403')) { + return self::REASON_PERMISSION_DENIED; + } + + if (str_contains($candidate, 'validation') || str_contains($candidate, 'not_found') || str_contains($candidate, 'bad_request') || str_contains($candidate, '400') || str_contains($candidate, '422')) { + return self::REASON_VALIDATION_ERROR; + } + + if (str_contains($candidate, 'conflict') || str_contains($candidate, '409')) { + return self::REASON_CONFLICT_DETECTED; + } + + return self::REASON_UNKNOWN_ERROR; + } + + public static function sanitizeMessage(string $message): string + { + $message = trim(str_replace(["\r", "\n"], ' ', $message)); + + // Redact obvious PII (emails). + $message = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $message) ?? $message; + + // Redact obvious bearer tokens / secrets. + $message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', 'Bearer [REDACTED]', $message) ?? $message; + $message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\s*[:=]\s*[^\s]+/i', '$1=[REDACTED]', $message) ?? $message; + + // Redact long opaque blobs that look token-like. + $message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message; + + return substr($message, 0, 120); + } +} diff --git a/app/Support/RunIdempotency.php b/app/Support/RestoreRunIdempotency.php similarity index 84% rename from app/Support/RunIdempotency.php rename to app/Support/RestoreRunIdempotency.php index 574afcf..5035dbc 100644 --- a/app/Support/RunIdempotency.php +++ b/app/Support/RestoreRunIdempotency.php @@ -2,11 +2,10 @@ namespace App\Support; -use App\Models\BulkOperationRun; use App\Models\RestoreRun; use Illuminate\Support\Arr; -final class RunIdempotency +final class RestoreRunIdempotency { /** * @param array $context @@ -23,16 +22,6 @@ public static function buildKey(int $tenantId, string $operationType, string|int return hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)); } - public static function findActiveBulkOperationRun(int $tenantId, string $idempotencyKey): ?BulkOperationRun - { - return BulkOperationRun::query() - ->where('tenant_id', $tenantId) - ->where('idempotency_key', $idempotencyKey) - ->whereIn('status', ['pending', 'running']) - ->latest('id') - ->first(); - } - public static function findActiveRestoreRun(int $tenantId, string $idempotencyKey): ?RestoreRun { return RestoreRun::query() diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 23cef34..a82a113 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -320,6 +320,10 @@ 'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3), 'recent_finished_seconds' => (int) env('TENANTPILOT_BULK_RECENT_FINISHED_SECONDS', 12), 'progress_widget_enabled' => (bool) env('TENANTPILOT_BULK_PROGRESS_WIDGET_ENABLED', true), + 'concurrency' => [ + 'per_target_scope_max' => (int) env('TENANTPILOT_BULK_CONCURRENCY_PER_TARGET_SCOPE_MAX', 1), + 'lock_ttl_seconds' => (int) env('TENANTPILOT_BULK_CONCURRENCY_LOCK_TTL_SECONDS', 900), + ], ], 'inventory_sync' => [ diff --git a/database/factories/BulkOperationRunFactory.php b/database/factories/BulkOperationRunFactory.php deleted file mode 100644 index 55503f0..0000000 --- a/database/factories/BulkOperationRunFactory.php +++ /dev/null @@ -1,34 +0,0 @@ - - */ -class BulkOperationRunFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'tenant_id' => \App\Models\Tenant::factory(), - 'user_id' => \App\Models\User::factory(), - 'resource' => 'policy', - 'action' => 'delete', - 'status' => 'pending', - 'total_items' => 10, - 'processed_items' => 0, - 'succeeded' => 0, - 'failed' => 0, - 'skipped' => 0, - 'item_ids' => range(1, 10), - 'failures' => [], - ]; - } -} diff --git a/database/migrations/2026_01_18_000001_drop_bulk_operation_runs_table.php b/database/migrations/2026_01_18_000001_drop_bulk_operation_runs_table.php new file mode 100644 index 0000000..1fe3e60 --- /dev/null +++ b/database/migrations/2026_01_18_000001_drop_bulk_operation_runs_table.php @@ -0,0 +1,45 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('resource', 50); + $table->string('action', 50); + $table->string('idempotency_key', 64)->nullable(); + $table->string('status', 50)->default('pending'); + $table->unsignedInteger('total_items'); + $table->unsignedInteger('processed_items')->default(0); + $table->unsignedInteger('succeeded')->default(0); + $table->unsignedInteger('failed')->default(0); + $table->unsignedInteger('skipped')->default(0); + $table->jsonb('item_ids'); + $table->jsonb('failures')->nullable(); + $table->foreignId('audit_log_id')->nullable()->constrained()->nullOnDelete(); + $table->timestamps(); + }); + + Schema::table('bulk_operation_runs', function (Blueprint $table) { + $table->index(['tenant_id', 'resource', 'status'], 'bulk_runs_tenant_resource_status'); + $table->index(['user_id', 'created_at'], 'bulk_runs_user_created'); + $table->index(['tenant_id', 'idempotency_key'], 'bulk_runs_tenant_idempotency'); + }); + + DB::statement("CREATE INDEX bulk_runs_status_active ON bulk_operation_runs (status) WHERE status IN ('pending', 'running')"); + DB::statement("CREATE UNIQUE INDEX bulk_runs_idempotency_active ON bulk_operation_runs (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL AND status IN ('pending', 'running')"); + } +}; diff --git a/database/seeders/BulkOperationsTestSeeder.php b/database/seeders/BulkOperationsTestSeeder.php deleted file mode 100644 index 1229ac5..0000000 --- a/database/seeders/BulkOperationsTestSeeder.php +++ /dev/null @@ -1,33 +0,0 @@ -create(); - $user = \App\Models\User::first() ?? \App\Models\User::factory()->create(); - - // Create some policies to test bulk delete - \App\Models\Policy::factory()->count(30)->create([ - 'tenant_id' => $tenant->id, - 'policy_type' => 'deviceConfiguration', - ]); - - // Create a completed bulk run - \App\Models\BulkOperationRun::factory()->create([ - 'tenant_id' => $tenant->id, - 'user_id' => $user->id, - 'status' => 'completed', - 'total_items' => 10, - 'processed_items' => 10, - 'succeeded' => 10, - ]); - } -} diff --git a/routes/console.php b/routes/console.php index de19a60..ce2139d 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,7 @@ daily() ->name(PruneOldOperationRunsJob::class) ->withoutOverlapping(); + +Schedule::job(new ReconcileAdapterRunsJob) + ->everyThirtyMinutes() + ->name(ReconcileAdapterRunsJob::class) + ->withoutOverlapping(); diff --git a/specs/005-bulk-operations/quickstart.md b/specs/005-bulk-operations/quickstart.md index 5712cea..564755f 100644 --- a/specs/005-bulk-operations/quickstart.md +++ b/specs/005-bulk-operations/quickstart.md @@ -44,7 +44,8 @@ ### 2. Run Migrations ### 3. Seed Test Data (Optional) ```bash -./vendor/bin/sail artisan db:seed --class=BulkOperationsTestSeeder +# NOTE: Removed by Feature 056 (OperationRun migration). +# There is no BulkOperationsTestSeeder anymore. ``` Creates: @@ -409,10 +410,10 @@ # Watch queue jobs in real-time # Monitor bulk operations ./vendor/bin/sail artisan tinker ->>> BulkOperationRun::inProgress()->get() +>>> \App\Models\OperationRun::query()->where('status', 'running')->get() # Seed more test data -./vendor/bin/sail artisan db:seed --class=BulkOperationsTestSeeder +# NOTE: Removed by Feature 056 (OperationRun migration). # Clear cache ./vendor/bin/sail artisan optimize:clear diff --git a/specs/056-remove-legacy-bulkops/checklists/requirements.md b/specs/056-remove-legacy-bulkops/checklists/requirements.md new file mode 100644 index 0000000..0473305 --- /dev/null +++ b/specs/056-remove-legacy-bulkops/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Remove Legacy BulkOperationRun & Canonicalize Operations (v1.0) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-18 +**Feature**: [specs/056-remove-legacy-bulkops/spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation pass: Spec describes the single-run model, action taxonomy, and MSP safety expectations in testable terms. +- Implementation specifics (service names, job design, exact key registries) are intentionally omitted; they belong in the plan/tasks phase. diff --git a/specs/056-remove-legacy-bulkops/contracts/operation-run-context.bulk.schema.json b/specs/056-remove-legacy-bulkops/contracts/operation-run-context.bulk.schema.json new file mode 100644 index 0000000..b17dce0 --- /dev/null +++ b/specs/056-remove-legacy-bulkops/contracts/operation-run-context.bulk.schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "operation-run-context.bulk.schema.json", + "title": "OperationRun Context — Bulk Operation", + "type": "object", + "additionalProperties": true, + "properties": { + "operation": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { "type": "string", "minLength": 1 } + }, + "required": ["type"] + }, + "target_scope": { + "type": "object", + "additionalProperties": true, + "properties": { + "entra_tenant_id": { "type": "string", "minLength": 1 }, + "directory_context_id": { "type": "string", "minLength": 1 } + }, + "anyOf": [ + { "required": ["entra_tenant_id"] }, + { "required": ["directory_context_id"] } + ] + }, + "selection": { + "type": "object", + "additionalProperties": true, + "properties": { + "kind": { "type": "string", "enum": ["ids", "query"] }, + "ids_hash": { "type": "string", "minLength": 1 }, + "query_hash": { "type": "string", "minLength": 1 } + }, + "allOf": [ + { + "if": { "properties": { "kind": { "const": "ids" } }, "required": ["kind"] }, + "then": { "required": ["ids_hash"], "not": { "required": ["query_hash"] } } + }, + { + "if": { "properties": { "kind": { "const": "query" } }, "required": ["kind"] }, + "then": { "required": ["query_hash"], "not": { "required": ["ids_hash"] } } + } + ], + "required": ["kind"] + }, + "idempotency": { + "type": "object", + "additionalProperties": true, + "properties": { + "fingerprint": { "type": "string", "minLength": 1 } + }, + "required": ["fingerprint"] + } + }, + "required": ["operation", "selection", "idempotency"] +} diff --git a/specs/056-remove-legacy-bulkops/contracts/operations.bulk.openapi.yaml b/specs/056-remove-legacy-bulkops/contracts/operations.bulk.openapi.yaml new file mode 100644 index 0000000..5913f3d --- /dev/null +++ b/specs/056-remove-legacy-bulkops/contracts/operations.bulk.openapi.yaml @@ -0,0 +1,66 @@ +openapi: 3.0.3 +info: + title: TenantAtlas Operations — Bulk Enqueue (Conceptual) + version: 0.1.0 + description: | + Conceptual contract for enqueue-only bulk operations. + + Notes: + - This contract describes the shape of inputs and outputs; it does not prescribe a specific Laravel route. + - Start surfaces are enqueue-only and must not perform remote work inline. +paths: + /operations/bulk/enqueue: + post: + summary: Enqueue a bulk operation (enqueue-only) + operationId: enqueueBulkOperation + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + properties: + operation_type: + type: string + minLength: 1 + target_scope: + type: object + additionalProperties: true + properties: + entra_tenant_id: + type: string + directory_context_id: + type: string + selection: + type: object + additionalProperties: true + properties: + kind: + type: string + enum: [ids, query] + ids: + type: array + items: + type: string + query: + type: object + additionalProperties: true + required: [operation_type, selection] + responses: + '202': + description: Enqueued or deduped to an existing active run + content: + application/json: + schema: + type: object + additionalProperties: true + properties: + operation_run_id: + type: integer + status: + type: string + enum: [queued, running] + view_run_url: + type: string + required: [operation_run_id, status] diff --git a/specs/056-remove-legacy-bulkops/data-model.md b/specs/056-remove-legacy-bulkops/data-model.md new file mode 100644 index 0000000..4903c43 --- /dev/null +++ b/specs/056-remove-legacy-bulkops/data-model.md @@ -0,0 +1,87 @@ +# Phase 1 — Data Model: Remove Legacy BulkOperationRun & Canonicalize Operations (v1.0) + +This document describes the domain entities and data contracts impacted by Feature 056. + +## Entities + +### OperationRun (canonical) + +Represents a single observable operation execution. Tenant-scoped. + +**Core fields (existing):** + +- `tenant_id`: Tenant scope for isolation. +- `user_id`: Initiator user (nullable for system). +- `initiator_name`: Human name for display. +- `type`: Operation type (stable identifier). +- `status`: queued | running | completed. +- `outcome`: pending | succeeded | partial | failed (exact naming per existing app semantics). +- `run_identity_hash`: Deterministic identity used for dedupe of active runs. +- `context`: JSON object containing operation inputs and metadata. +- `summary_counts`: JSON object with canonical metrics keys. +- `failure_summary`: JSON array/object with stable reason codes + sanitized messages. +- `started_at`, `completed_at`: Timestamps. + +**Invariants:** + +- Active-run dedupe is tenant-wide. +- Monitoring renders from DB only. +- `summary_counts` keys are constrained to the canonical registry. +- Failure messages are sanitized and do not include secrets. + +### Operation Type (logical) + +A stable identifier used to categorize runs, label them for admins, and enforce consistent UX. + +**Attributes:** + +- `type` (string): e.g., `policy_version.prune`, `backup_set.delete`, etc. +- `label` (string): human readable name. +- `expected_duration_seconds` (optional): typical duration. +- `allowed_summary_keys`: canonical registry. + +### Target Scope (logical) + +A directory/remote tenant scope that an operation targets. + +**Context fields (when applicable):** + +- `entra_tenant_id`: Azure AD / Entra tenant GUID. +- `directory_context_id`: Internal directory context identifier. + +At least one of the above must be present for directory-targeted operations. + +### Bulk Selection Identity (logical) + +Deterministic definition of “what the bulk action applies to”, required for idempotency. + +**Decision**: Hybrid identity. + +- Explicit selection: `selection.ids_hash` +- Query/filter selection (“select all”): `selection.query_hash` + +**Properties:** + +- `selection.kind`: `ids` | `query` +- `selection.ids_hash`: sha256 of stable, sorted IDs. +- `selection.query_hash`: sha256 of normalized filter/query payload. + +### Bulk Idempotency Fingerprint (logical) + +Deterministic fingerprint used to dedupe active runs and prevent duplicated work. + +**Components:** + +- operation `type` +- target scope (`entra_tenant_id` or `directory_context_id`) +- selection identity (hybrid) + +## Removed Entity + +### BulkOperationRun (legacy) + +This entity is removed by Feature 056. + +- Legacy model/service/table are deleted. +- No new writes to legacy tables after cutover. +- Historical import into OperationRun is not performed. diff --git a/specs/056-remove-legacy-bulkops/discovery.md b/specs/056-remove-legacy-bulkops/discovery.md new file mode 100644 index 0000000..a657539 --- /dev/null +++ b/specs/056-remove-legacy-bulkops/discovery.md @@ -0,0 +1,76 @@ +# Discovery Report: Feature 056 — Remove Legacy BulkOperationRun + +## Purpose + +This report records the repo-wide sweep of legacy BulkOperationRun usage and any bulk-like actions that must be classified and migrated to canonical `OperationRun`. + +## Legacy History Decision + +- Default path: legacy BulkOperationRun history is **not** migrated into `OperationRun`. +- After cutover, legacy tables are removed; historical investigation relies on database backups/exports if needed. + +## Sweep Checklist + +- [x] app/ (Models, Services, Jobs, Notifications, Support) +- [x] app/Filament/ (Resources, Pages, Actions) +- [x] database/ (migrations, factories, seeders) +- [ ] resources/ (views) +- [ ] routes/ (web, console) +- [ ] tests/ (Feature, Unit) + +## Findings + +### A) Legacy artifacts (to remove) + +| Kind | Path | Notes | +|------|------|-------| +| Model | app/Models/BulkOperationRun.php | Legacy run model; referenced across jobs and UI. | +| Service | app/Services/BulkOperationService.php | Legacy run lifecycle + failure recording; widely referenced. | +| Filament Resource | app/Filament/Resources/BulkOperationRunResource.php | Legacy Monitoring surface; must be removed (Monitoring uses OperationRun). | +| Filament Pages | app/Filament/Resources/BulkOperationRunResource/Pages/ListBulkOperationRuns.php | Legacy list page. | +| Filament Pages | app/Filament/Resources/BulkOperationRunResource/Pages/ViewBulkOperationRun.php | Legacy detail page. | +| Policy | app/Policies/BulkOperationRunPolicy.php | Legacy authorization policy. | +| Policy Registration | app/Providers/AppServiceProvider.php | Registers BulkOperationRun policy/gate mapping. | +| Helper | app/Support/RunIdempotency.php | `findActiveBulkOperationRun(...)` helper for legacy dedupe. | +| Command | app/Console/Commands/TenantpilotPurgeNonPersistentData.php | Still references BulkOperationRun and legacy table counts. | +| DB Migrations | database/migrations/*bulk_operation_runs* | Legacy table creation + follow-up schema changes. | +| Factory | database/factories/BulkOperationRunFactory.php | Test factory to remove after cutover. | +| Seeder | database/seeders/BulkOperationsTestSeeder.php | Test seed data to remove after cutover. | +| Table | bulk_operation_runs | Legacy DB table; drop via forward migration after cutover. | + +### B) Bulk-like start surfaces (to migrate) + +| Surface | Path | Operation type | Target scope? | Notes | +|---------|------|----------------|---------------|-------| +| Policy bulk delete | app/Filament/Resources/PolicyResource.php | `policy.delete` | Yes (tenant + directory scope) | Migrates to OperationRun-backed enqueue + orchestrator/worker. | +| Backup set bulk delete | app/Filament/Resources/BackupSetResource.php | `backup_set.delete` | Yes | Migrates to OperationRun-backed enqueue + orchestrator/worker. | +| Policy version prune | app/Filament/Resources/PolicyVersionResource.php | `policy_version.prune` | Yes | Migrates to OperationRun-backed enqueue + orchestrator/worker. | +| Policy version force delete | app/Filament/Resources/PolicyVersionResource.php | `policy_version.force_delete` | Yes | Migrates to OperationRun-backed enqueue + orchestrator/worker. | +| Restore run bulk delete | app/Filament/Resources/RestoreRunResource.php | `restore_run.delete` | Yes | Migrates to OperationRun-backed enqueue + orchestrator/worker. | +| Tenant bulk sync | app/Filament/Resources/TenantResource.php | `tenant.sync` | Yes | Migrates to OperationRun-backed enqueue + orchestrator/worker. | +| Policy snapshot capture | app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php | `policy.capture_snapshot` | Yes | Migrates to OperationRun-backed enqueue + orchestrator/worker. | +| Drift generation | app/Filament/Pages/DriftLanding.php | `drift.generate` | Yes | Mixed legacy + OperationRun today; needs full canonicalization. | +| Backup set add policies (picker) | app/Livewire/BackupSetPolicyPickerTable.php | `backup_set.add_policies` | Yes | Mixed legacy + OperationRun today; remove legacy dedupe + legacy links. | + +### C) Bulk-like jobs/workers (to migrate) + +| Job | Path | Remote calls? | Notes | +|-----|------|--------------|------| +| Policy bulk delete | app/Jobs/BulkPolicyDeleteJob.php | Likely (Graph) | Legacy bulk job; replaced by orchestrator/worker pattern. | +| Backup set bulk delete | app/Jobs/BulkBackupSetDeleteJob.php | Likely (Graph) | Legacy bulk job; replaced by orchestrator/worker pattern. | +| Policy version prune | app/Jobs/BulkPolicyVersionPruneJob.php | Likely (Graph) | Legacy bulk job; replaced by orchestrator/worker pattern. | +| Policy version force delete | app/Jobs/BulkPolicyVersionForceDeleteJob.php | Likely (Graph) | Legacy bulk job; replaced by orchestrator/worker pattern. | +| Restore run bulk delete | app/Jobs/BulkRestoreRunDeleteJob.php | Likely (Graph) | Legacy bulk job; replaced by orchestrator/worker pattern. | +| Tenant bulk sync | app/Jobs/BulkTenantSyncJob.php | Likely (Graph) | Legacy bulk job; replaced by orchestrator/worker pattern. | +| Policy snapshot capture | app/Jobs/CapturePolicySnapshotJob.php | Likely (Graph) | Legacy-ish job; migrate to orchestrator/worker and canonical run updates. | +| Drift generator | app/Jobs/GenerateDriftFindingsJob.php | Likely (Graph) | Already carries OperationRun; remove remaining legacy coupling. | +| Backup set add policies | app/Jobs/AddPoliciesToBackupSetJob.php | Likely (Graph) | Contains a legacy “fallback link” to BulkOperationRunResource; canonicalize. | +| Policy bulk sync (legacy) | app/Jobs/BulkPolicySyncJob.php | Likely (Graph) | Legacy bulk job; migrate/remove as part of full cutover. | + +### D) “View run” links (to canonicalize) + +| Location | Path | Current link target | Fix | +|----------|------|---------------------|-----| +| Run-status notification | app/Notifications/RunStatusChangedNotification.php | `BulkOperationRunResource::getUrl('view', ...)` | Route via `OperationRunLinks::view(...)` (OperationRun is canonical). | +| Add policies job notification links | app/Jobs/AddPoliciesToBackupSetJob.php | Conditional: OperationRun OR legacy BulkOperationRunResource view | Remove legacy fallback; always use OperationRun-backed links. | +| Legacy resource itself | app/Filament/Resources/BulkOperationRunResource.php | Legacy list/detail routes exist | Remove resource; rely on Monitoring → Operations. | diff --git a/specs/056-remove-legacy-bulkops/plan.md b/specs/056-remove-legacy-bulkops/plan.md new file mode 100644 index 0000000..c440076 --- /dev/null +++ b/specs/056-remove-legacy-bulkops/plan.md @@ -0,0 +1,163 @@ +# Implementation Plan: Remove Legacy BulkOperationRun & Canonicalize Operations (v1.0) + +**Branch**: `056-remove-legacy-bulkops` | **Date**: 2026-01-18 | **Spec**: [specs/056-remove-legacy-bulkops/spec.md](./spec.md) +**Input**: Feature specification from `/specs/056-remove-legacy-bulkops/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Unify all bulk/operational work onto `OperationRun` (canonical run model + Monitoring surface) by migrating all legacy `BulkOperationRun` workflows to OperationRun-backed orchestration, removing the legacy stack (model/service/table/UI), and adding guardrails that prevent reintroduction. + +## Technical Context + +**Language/Version**: PHP 8.4.x +**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3 +**Storage**: PostgreSQL (JSONB in `operation_runs.context`, `operation_runs.summary_counts`) +**Testing**: Pest (PHPUnit 12) +**Target Platform**: Docker via Laravel Sail (local); Dokploy (staging/prod) +**Project Type**: Web application (Laravel monolith) +**Performance Goals**: Calm polling UX for Monitoring; bulk orchestration chunked and resilient to throttling; per-scope concurrency default=1 +**Constraints**: Tenant isolation; no secrets in run failures/notifications; no remote work during UI render; Monitoring is DB-only +**Scale/Scope**: Bulk actions may target large selections; orchestration must remain idempotent and debounced by run identity + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: N/A (this feature is operations plumbing; no inventory semantics change) +- Read/write separation: PASS (bulk actions remain enqueue-only; write paths are job-backed and auditable) +- Graph contract path: PASS (no new render-side Graph calls; any remote work stays behind queue + existing Graph client boundary) +- Deterministic capabilities: PASS (no capability derivation changes) +- Tenant isolation: PASS (OperationRun is tenant-scoped; bulk dedupe is tenant-wide; selection identity is deterministic) +- Run observability: PASS (bulk is always OperationRun-backed; DB-only <2s actions remain audit-only) +- Automation: PASS (locks + idempotency required; per-target concurrency is config-driven default=1) +- Data minimization: PASS (failure summaries stay sanitized; no secrets in run records) + +## Project Structure + +### Documentation (this feature) + +```text +specs/056-remove-legacy-bulkops/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/ +│ ├── Resources/ +│ └── Pages/ +├── Jobs/ +├── Models/ +├── Notifications/ +├── Services/ +└── Support/ + +config/ +├── tenantpilot.php +└── graph_contracts.php + +database/ +├── migrations/ +└── factories/ + +resources/ +└── views/ + +routes/ +└── web.php + +tests/ +├── Feature/ +└── Unit/ +``` + +**Structure Decision**: Laravel web application (monolith). Feature changes are expected primarily under `app/` (runs, jobs, Filament actions), `database/migrations/` (dropping legacy tables), and `tests/` (Pest guardrails). + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| None | N/A | N/A | + +## Constitution Check (Post-Design) + +*Re-check after Phase 1 outputs are complete.* + +- Monitoring remains DB-only at render time. +- Start surfaces remain enqueue-only. +- Bulk work is always OperationRun-backed. +- Per-target scope concurrency is config-driven (default=1). +- Bulk idempotency uses hybrid selection identity. + +## Phase 0 — Outline & Research (output: research.md) + +### Discovery signals (repo sweep) + +Legacy bulk-run usage exists and must be migrated/removed: + +- Jobs using legacy run/service include: + - `app/Jobs/BulkPolicySyncJob.php` + - `app/Jobs/BulkPolicyDeleteJob.php` + - `app/Jobs/BulkBackupSetDeleteJob.php` + - `app/Jobs/BulkPolicyVersionPruneJob.php` + - `app/Jobs/BulkPolicyVersionForceDeleteJob.php` + - `app/Jobs/BulkRestoreRunDeleteJob.php` + - `app/Jobs/BulkTenantSyncJob.php` + - `app/Jobs/CapturePolicySnapshotJob.php` + - `app/Jobs/GenerateDriftFindingsJob.php` (mixed: legacy + OperationRun) + +Legacy data layer present: + +- Model: `app/Models/BulkOperationRun.php` +- Service: `app/Services/BulkOperationService.php` +- Migrations: `database/migrations/*_create_bulk_operation_runs_table.php`, `*_add_idempotency_key_to_bulk_operation_runs_table.php`, `*_increase_bulk_operation_runs_status_length.php` +- Factory/Seeder: `database/factories/BulkOperationRunFactory.php`, `database/seeders/BulkOperationsTestSeeder.php` + +Existing canonical run patterns to reuse: + +- `app/Services/OperationRunService.php` provides tenant-wide active-run dedupe and safe dispatch semantics. +- `app/Support/OperationCatalog.php` centralizes labels/durations and allowed summary keys. +- `app/Support/OpsUx/OperationSummaryKeys.php` + `SummaryCountsNormalizer` enforce summary_counts contract. + +Concurrency/idempotency patterns already exist and should be adapted: + +- `app/Services/Inventory/InventoryConcurrencyLimiter.php` uses per-scope cache locks with config-driven defaults. +- `app/Services/Inventory/InventorySyncService.php` uses selection hashing + selection locks to prevent duplicate work. + +### Research outputs (decisions) + +- Per-target scope concurrency is configuration-driven with default=1. +- Bulk selection identity is hybrid (IDs-hash when explicit IDs; query-hash when “select all via filter/query”). +- Legacy bulk-run history is not imported into OperationRun (default path). + +## Phase 1 — Design & Contracts (outputs: data-model.md, contracts/*, quickstart.md) + +### Design topics + +- Define the canonical bulk orchestration shape around OperationRun (orchestrator + workers) while preserving the constitution feedback surfaces. +- Define the minimal run context contract for directory-targeted runs (target scope fields + idempotency fingerprint fields). +- Extend operation type catalog for newly migrated bulk operations. + +### Contract artifacts + +- JSON schema for `operation_runs.context` for bulk operations (target scope + selection identity + idempotency). +- OpenAPI sketch for internal operation-triggering endpoints (if applicable) as a stable contract for “enqueue-only” start surfaces. + +## Phase 2 — Planning (implementation outline; detailed tasks live in tasks.md) + +- Perform required discovery sweep and create a migration report. +- Migrate each legacy bulk workflow to OperationRun-backed orchestration. +- Remove legacy model/service/table/UI surfaces. +- Add hard guardrails (CI/test) to forbid legacy references and verify run-backed behavior. +- Add targeted Pest tests for bulk actions, per-scope throttling default=1, and summary key normalization. diff --git a/specs/056-remove-legacy-bulkops/quickstart.md b/specs/056-remove-legacy-bulkops/quickstart.md new file mode 100644 index 0000000..1808a51 --- /dev/null +++ b/specs/056-remove-legacy-bulkops/quickstart.md @@ -0,0 +1,52 @@ +# Quickstart: Feature 056 — Remove Legacy BulkOperationRun + +## Prerequisites + +- Local dev via Laravel Sail +- Database migrations up to date + +## Common commands (Sail-first) + +- Boot: `./vendor/bin/sail up -d` +- Run migrations: `./vendor/bin/sail artisan migrate` +- Run targeted tests (Ops UX): `./vendor/bin/sail artisan test tests/Feature/OpsUx` +- Run a single test file: `./vendor/bin/sail artisan test tests/Feature/OpsUx/BulkEnqueueIdempotencyTest.php` +- Run legacy guard test: `./vendor/bin/sail artisan test tests/Feature/Guards/NoLegacyBulkOperationsTest.php` +- Run queue worker (local): `./vendor/bin/sail artisan queue:work --verbose --tries=3 --timeout=300` +- Format (required): `./vendor/bin/pint --dirty` + +## Validation (targeted) + +- Ops UX suite: `./vendor/bin/sail artisan test tests/Feature/OpsUx` +- Legacy reference guard: `./vendor/bin/sail artisan test tests/Feature/Guards/NoLegacyBulkOperationsTest.php` + +## Ops commands + +- Preview adapter reconciliation (dry run): `./vendor/bin/sail artisan ops:reconcile-adapter-runs --dry-run=true` +- Apply adapter reconciliation: `./vendor/bin/sail artisan ops:reconcile-adapter-runs --dry-run=false` + +## What to build (high level) + +- Replace all legacy bulk-run usage with the canonical OperationRun run model. +- Ensure all bulk actions are enqueue-only and visible in Monitoring → Operations. +- Enforce per-target scope concurrency limit (config-driven, default=1). +- Enforce bulk idempotency via deterministic fingerprinting. +- Remove legacy BulkOperationRun stack (model/service/table/UI). +- Add guardrails/tests to prevent reintroduction. + +## Where to look first + +- Legacy stack: + - `app/Models/BulkOperationRun.php` + - `app/Services/BulkOperationService.php` + - `database/migrations/*bulk_operation_runs*` + +- Canonical run stack: + - `app/Models/OperationRun.php` + - `app/Services/OperationRunService.php` + - `app/Support/OperationCatalog.php` + - `app/Support/OpsUx/OperationSummaryKeys.php` + +- Locking patterns to reuse: + - `app/Services/Inventory/InventoryConcurrencyLimiter.php` + - `app/Services/Inventory/InventorySyncService.php` diff --git a/specs/056-remove-legacy-bulkops/research.md b/specs/056-remove-legacy-bulkops/research.md new file mode 100644 index 0000000..b50b7de --- /dev/null +++ b/specs/056-remove-legacy-bulkops/research.md @@ -0,0 +1,89 @@ +# Phase 0 — Research: Remove Legacy BulkOperationRun & Canonicalize Operations (v1.0) + +**Branch**: 056-remove-legacy-bulkops +**Date**: 2026-01-18 + +## Goals for Research + +- Resolve spec clarifications and translate them into concrete implementation constraints. +- Identify existing repo patterns for: + - run identity / dedupe + - summary_counts normalization + - locks / concurrency limiting + - idempotent selection hashing +- Enumerate known legacy usage locations to inform the discovery report. + +## Findings & Decisions + +### Decision 1 — Per-target scope concurrency + +- **Decision**: Concurrency limits are configuration-driven **with default = 1** per target scope. +- **Rationale**: Default=1 is the safest choice against Graph throttling and reduces blast radius when many tenants/scope targets are active. +- **Alternatives considered**: + - Default=2: more throughput but higher throttling risk. + - Default=5: increases throttling/incident risk. + - Hardcoded values: hard to tune per environment. +- **Implementation constraint**: Limit is enforced per `entra_tenant_id` or `directory_context_id`. + +### Decision 2 — Selection Identity (idempotency fingerprint) + +- **Decision**: Hybrid selection identity. + - If the user explicitly selects IDs: fingerprint includes an **IDs-hash**. + - If the user selects via filter/query (“select all”): fingerprint includes a **query/filter hash**. +- **Rationale**: Supports both UX patterns and avoids duplicate runs while remaining deterministic. +- **Alternatives considered**: + - IDs-hash only: cannot represent “select all by filter”. + - Query-hash only: cannot safely represent explicit selections. + - Always store both: increases complexity without clear value. + +### Decision 3 — Legacy history handling + +- **Decision**: Do not import legacy bulk-run history into OperationRun. +- **Rationale**: Minimizes migration risk and avoids polluting the canonical run surface with “synthetic” imported data. +- **Alternatives considered**: + - One-time import: adds complexity, new semantics, and additional testing burden. + +### Decision 4 — Canonical summary metrics + +- **Decision**: Summary metrics are derived and rendered from `operation_runs.summary_counts` using the canonical key registry. +- **Rationale**: Ensures consistent Monitoring UX and prevents ad-hoc keys. +- **Alternatives considered**: + - Per-operation bespoke metrics: causes UX drift and breaks shared widgets. + +### Decision 5 — Reuse existing repo patterns + +- **Decision**: Reuse existing run, lock, and selection hashing patterns already present in the repository. +- **Rationale**: Aligns with constitution and avoids divergent implementations. +- **Evidence in repo**: + - `OperationRunService` provides tenant-wide active-run dedupe and safe dispatch failure handling. + - `OperationCatalog` centralizes labels/durations and allowed summary keys. + - `InventoryConcurrencyLimiter` shows slot-based lock acquisition with config-driven maxima. + - `InventorySyncService` shows deterministic selection hashing and selection-level locks. + +## Legacy Usage Inventory (initial) + +This is a starting list derived from a repo search; the Phase 2 Discovery Report must expand/confirm. + +- Legacy bulk-run model: `app/Models/BulkOperationRun.php` +- Legacy bulk-run service: `app/Services/BulkOperationService.php` +- Legacy jobs using BulkOperationRun/Service: + - `app/Jobs/BulkPolicySyncJob.php` + - `app/Jobs/BulkPolicyDeleteJob.php` + - `app/Jobs/BulkBackupSetDeleteJob.php` + - `app/Jobs/BulkPolicyVersionPruneJob.php` + - `app/Jobs/BulkPolicyVersionForceDeleteJob.php` + - `app/Jobs/BulkRestoreRunDeleteJob.php` + - `app/Jobs/BulkTenantSyncJob.php` + - `app/Jobs/CapturePolicySnapshotJob.php` + - `app/Jobs/GenerateDriftFindingsJob.php` (mixed usage) +- Legacy DB artifacts: + - `database/migrations/2025_12_23_215901_create_bulk_operation_runs_table.php` + - `database/migrations/2026_01_11_120001_add_idempotency_key_to_bulk_operation_runs_table.php` + - `database/migrations/2025_12_24_005055_increase_bulk_operation_runs_status_length.php` +- Legacy test data: + - `database/factories/BulkOperationRunFactory.php` + - `database/seeders/BulkOperationsTestSeeder.php` + +## Open Questions + +None remaining for Phase 0 (spec clarifications resolved). Any additional unknowns found during discovery are to be added to the Phase 2 discovery report and/or tasks. diff --git a/specs/056-remove-legacy-bulkops/spec.md b/specs/056-remove-legacy-bulkops/spec.md new file mode 100644 index 0000000..bf24d6e --- /dev/null +++ b/specs/056-remove-legacy-bulkops/spec.md @@ -0,0 +1,190 @@ +# Feature Specification: Remove Legacy BulkOperationRun & Canonicalize Operations (v1.0) + +**Feature Branch**: `056-remove-legacy-bulkops` +**Created**: 2026-01-18 +**Status**: Draft +**Input**: User description: "Feature 056 — Remove Legacy BulkOperationRun & Canonicalize Operations (v1.0)" + +## Clarifications + +### Session 2026-01-18 + +- Q: What should be the default max concurrency per target scope (entra_tenant_id / directory_context_id) for bulk operations? → A: Config-driven, default=1 +- Q: How should Selection Identity be determined for idempotency fingerprinting? → A: Hybrid (IDs-hash for explicit selection; query-hash for “select all via filter/query”) + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Run-backed bulk actions are always observable (Priority: P1) + +An admin performs a bulk action (e.g., apply/ignore/restore/prune across many records). The system records a single canonical run that can be monitored end-to-end, including partial failures, and provides consistent user feedback. + +**Why this priority**: Bulk changes are operationally significant and must be traceable, support partial outcomes, and have a consistent mental model for admins. + +**Independent Test**: Trigger a representative bulk action and verify that a run record exists, appears in the Monitoring list, has a detail view, and emits the correct feedback surfaces. + +**Acceptance Scenarios**: + +1. **Given** an admin selects multiple items for a bulk action, **When** the action is confirmed and submitted, **Then** a canonical run record is created or reused and the UI confirms the enqueue/queued state via a toast. +2. **Given** a bulk run is queued or running, **When** the admin opens Monitoring → Operations, **Then** the run appears in the list and can be opened via a canonical “View run” link. +3. **Given** a bulk run completes with a mix of successes and failures, **When** the run reaches a terminal state, **Then** the initiator receives a terminal notification and the run detail shows a summary of outcomes. + +--- + +### User Story 2 - Monitoring is the single source of run history (Priority: P2) + +An admin (or operator) relies on Monitoring → Operations to see the full history of operational work (including bulk). There are no separate legacy run surfaces; links from anywhere in the app point to the canonical run detail. + +**Why this priority**: Multiple run systems lead to missed incidents, inconsistent retention, and developer confusion. One canonical surface improves operational clarity and reduces support overhead. + +**Independent Test**: Navigate from a bulk action result to “View run” and confirm it lands in Monitoring’s run detail; confirm there is no legacy “bulk runs” navigation or pages. + +**Acceptance Scenarios**: + +1. **Given** any UI element offers a “View run” link, **When** it is clicked, **Then** it opens the canonical Monitoring → Operations → Run Detail page for that run. +2. **Given** the app navigation, **When** an admin searches for legacy bulk-run screens, **Then** no legacy bulk-run navigation or pages exist. + +--- + +### User Story 3 - Developers can’t accidentally reintroduce legacy patterns (Priority: P3) + +A developer adds or modifies an admin action. They can clearly determine whether it is an audit-only action or a run-backed operation, and the repository enforces the single-run model by preventing legacy references and UX drift. + +**Why this priority**: Preventing regression is essential for suite readiness and long-term maintainability. + +**Independent Test**: Introduce a legacy reference or a bulk action without a run-backed record and confirm CI/automated checks fail. + +**Acceptance Scenarios**: + +1. **Given** a change introduces any reference to the legacy bulk-run system, **When** tests/CI run, **Then** the pipeline fails with a clear message. +2. **Given** a security-relevant DB-only action that is eligible for audit-only classification, **When** the action runs, **Then** an audit log entry is recorded and no run record is created. + +### Edge Cases + +- Bulk selection is empty or resolves to zero items: the system does not start work and provides a clear non-destructive result. +- A bulk selection is very large: the system remains responsive and continues to show progress via run summary metrics. +- Target scope is required but missing: the system fails safely, records a terminal run with a stable reason code, and does not execute remote/bulk mutations. +- Remote calls experience throttling: the system applies bounded retries with jittered backoff and records failures without losing overall run visibility. +- Duplicate submissions (double click / retry / re-run): idempotency prevents duplicate processing and preserves a single canonical outcome per selection identity. +- Tenant isolation: no run, selection, summary, or notifications leak across tenants. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature consolidates operational work onto a single canonical run model and a single monitoring surface. It must preserve the defined user feedback surfaces (queued toast, active widget, terminal notification), ensure tenant-scoped observability, and maintain stable, sanitized messages and reason codes. + +### Functional Requirements + +- **FR-001 Single run model**: The system MUST use a single canonical run model (`OperationRun`) for all run-backed operations; the legacy bulk-run model MUST not exist after this feature. +- **FR-002 Bulk actions are run-backed**: Any bulk action (apply to N records, chunked work, mass ignore/restore/prune/delete) MUST create or reuse an `OperationRun` and MUST be visible in Monitoring → Operations. +- **FR-003 Action taxonomy**: Every admin action MUST be classified as exactly one of: + - **Audit-only DB action**: DB-only, no remote/external calls, no queued work, and bounded DB work; typically completes within ~2 seconds (guidance, not a hard rule). MUST write an audit log for security/ops-relevant state changes; MUST NOT create an `OperationRun`. + - **Run-backed operation**: queued/long-running/remote/bulk/scheduled or otherwise operationally significant; MUST create or reuse an `OperationRun`. + +**Decision rule**: If classification is uncertain, default to **Run-backed operation**. +- **FR-004 Canonical UX surfaces**: For run-backed operations, the system MUST use only these feedback surfaces: + - **Queued**: toast-only + - **Active**: tenant-wide active widget + - **Terminal**: database-backed notification to the initiator only +- **FR-005 Canonical routing**: All “View run” links MUST route to Monitoring → Operations → Run Detail. +- **FR-006 Legacy removal**: The system MUST remove legacy bulk-run tables/models/services/routes/widgets/navigation and MUST prevent any new legacy writes. +- **FR-007 Canonical summary metrics**: The run’s summary metrics MUST use a single canonical set of keys and MUST be presented consistently in the run detail view. +- **FR-008 Target scope recording**: For operations targeting a directory/remote tenant, the run context MUST record the target scope (directory identifier) and Monitoring/Run Detail MUST display it in a human-friendly way when available. +- **FR-009 Per-target throttling**: Bulk orchestration MUST enforce concurrency limits per target scope to reduce throttling risk and provide predictable execution; the limit MUST be configuration-driven with a default of 1 per target scope. +- **FR-010 Idempotency for bulk**: Bulk operations MUST be idempotent using a deterministic fingerprint that includes operation type, target scope, and selection identity; retries MUST NOT duplicate work. +- **FR-011 Discovery completeness**: The implementation MUST include a repo-wide discovery sweep of legacy references and bulk-like actions; findings MUST be recorded in a discovery report with classification and migration/deferral decisions. +- **FR-012 Regression guardrails**: Automated checks MUST fail if legacy bulk-run references reappear or if bulk actions bypass the canonical run-backed model. + +### Non-Functional Requirements (NFR) + +#### NFR-01 Monitoring is DB-only at render time (Constitution Gate) + +All Monitoring → Operations pages (index and run detail) MUST be DB-only at render time: + +- No Graph/remote calls during initial render or reactive renders. +- No side-effectful work triggered by view rendering. + +**Verification**: + +- Add a regression test/guard that mocks the Graph client (or equivalent remote client) and asserts it is not called during Monitoring renders. + +- Add a regression test/guard that mocks the Graph client (or equivalent remote client) and asserts it is not called during Monitoring renders. + +#### NFR-02 Failure reason codes and message sanitization + +Run-backed operations MUST store failures as stable, machine-readable `reason_code` values plus a sanitized, user-facing message. + +**Minimal required reason_code set (baseline)**: + +| reason_code | Meaning | +|------------|---------| +| graph_throttled | Remote service throttled (e.g., rate limited) | +| graph_timeout | Remote call timed out | +| permission_denied | Missing/insufficient permissions | +| validation_error | Input/selection validation failure | +| conflict_detected | Conflict detected (concurrency/version/resource state) | +| unknown_error | Fallback when no specific code applies | + +**Rules**: + +- `reason_code` is stable over time and safe to use in programmatic filters/alerts. +- Failure messages are sanitized and bounded in length; failures/notifications MUST NOT persist secrets/tokens/PII or raw payload dumps. + +#### NFR-03 Retry/backoff/jitter for remote throttling + +When worker jobs perform remote calls, they MUST handle transient failures (including 429/503) via a shared policy: + +- bounded retries +- exponential backoff with jitter +- no hand-rolled `sleep()` loops or ad-hoc random retry logic in feature code + +### Implementation Shape (decision) + +**Decision: standard orchestrator + item workers** + +- 1 orchestrator job per run: + - resolves selection deterministically + - chunks work + - dispatches item worker jobs (idempotent per item) +- Worker jobs update `operation_runs.summary_counts` via canonical normalization. +- Finalization sets terminal status once. + +### Target Scope (canonical keys) + +**Canonical context keys**: + +- `entra_tenant_id` (Azure AD tenant GUID) +- optional `entra_tenant_name` (human-friendly; if available) +- optional `directory_context_id` (internal directory context identifier, if/when introduced) + +For operations targeting a directory/remote tenant, the run context MUST record target scope using the canonical keys above, and Monitoring/Run Detail MUST display the target scope (human-friendly name if available). + +#### Assumptions + +- Existing run status semantics remain unchanged (queued/running/succeeded/partial/failed). +- Existing monitoring experience is not redesigned; it is aligned so that all operational work is represented consistently. + +#### Dependencies + +- Prior consolidation work establishing `OperationRun` as the canonical run model and Monitoring → Operations as the canonical surface. +- Existing audit logging conventions for security/ops-relevant DB-only actions. + +#### Legacy History Decision (recorded) + +- Default path: legacy bulk-run history is not migrated into the canonical run model. The legacy tables are removed after cutover, relying on database backups/exports if historical investigation is needed. + +### Key Entities *(include if feature involves data)* + +- **OperationRun**: A tenant-scoped record of operational work with status, timestamps, sanitized user-facing message/reason code, summary metrics, and context. +- **Operation Type**: A stable identifier describing the kind of operation (used for categorization, labeling, and governance). +- **Target Scope**: The directory / remote tenant scope that the operation targets (when applicable). +- **Selection Identity**: The deterministic definition of “what the bulk action applies to” used for idempotency and traceability. +- **Audit Log Entry**: A record of security/ops-relevant state changes for audit-only DB actions. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of bulk actions in the admin UI create or reuse a canonical run record and appear in Monitoring → Operations. +- **SC-002**: Repository contains 0 references to the legacy bulk-run system after completion, enforced by automated checks. +- **SC-003**: For directory-targeted operations, 100% of run records display a target scope in Monitoring/Run Detail. +- **SC-004**: For bulk operations, duplicate submissions do not increase processed item count beyond one idempotent execution per selection identity. +- **SC-005**: Admins can locate a completed bulk run in Monitoring within 30 seconds using standard navigation and filters, without relying on legacy pages. diff --git a/specs/056-remove-legacy-bulkops/tasks.md b/specs/056-remove-legacy-bulkops/tasks.md new file mode 100644 index 0000000..b55aa58 --- /dev/null +++ b/specs/056-remove-legacy-bulkops/tasks.md @@ -0,0 +1,259 @@ +# Tasks: Remove Legacy BulkOperationRun & Canonicalize Operations (v1.0) + +**Input**: Design documents from `/specs/056-remove-legacy-bulkops/` +**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/, quickstart.md + +**Tests**: Required (Pest) +**Operations**: This feature consolidates queued/bulk work onto canonical `OperationRun` and removes the legacy `BulkOperationRun` system. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Ensure feature docs/paths are ready for implementation and review + +- [X] T001 Review and update quickstart commands in specs/056-remove-legacy-bulkops/quickstart.md +- [X] T002 [P] Create discovery report scaffold in specs/056-remove-legacy-bulkops/discovery.md +- [X] T003 [P] Add a “legacy history decision” section to specs/056-remove-legacy-bulkops/discovery.md + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared primitives required for all bulk migrations and hardening + +- [X] T004 Populate the full repo-wide discovery sweep in specs/056-remove-legacy-bulkops/discovery.md (app/, resources/, database/, tests/) +- [X] T005 [P] Add config keys for per-target scope concurrency (default=1) in config/tenantpilot.php +- [X] T006 [P] Register new bulk operation types/labels/durations in app/Support/OperationCatalog.php +- [X] T007 [P] Implement hybrid selection identity hasher in app/Services/Operations/BulkSelectionIdentity.php +- [X] T008 [P] Implement idempotency fingerprint builder in app/Services/Operations/BulkIdempotencyFingerprint.php +- [X] T009 [P] Implement per-target scope concurrency limiter (Cache locks) in app/Services/Operations/TargetScopeConcurrencyLimiter.php +- [X] T010 Extend OperationRun identity inputs to include target scope + selection identity in app/Services/OperationRunService.php +- [X] T011 Add a bulk enqueue helper that standardizes ensureRun + dispatchOrFail usage in app/Services/OperationRunService.php + +**Checkpoint**: Shared primitives exist; bulk migrations can proceed consistently + +--- + +## Phase 3: User Story 1 - Run-backed bulk actions are always observable (Priority: P1) 🎯 MVP + +**Goal**: All bulk actions are OperationRun-backed and observable end-to-end + +**Independent Test**: Trigger one migrated bulk action and verify OperationRun is created/reused, queued toast occurs, and Monitoring → Operations shows it. + +### Tests for User Story 1 + +- [X] T012 [P] [US1] Add bulk enqueue idempotency test in tests/Feature/OpsUx/BulkEnqueueIdempotencyTest.php +- [X] T013 [P] [US1] Add per-target concurrency default=1 test in tests/Feature/OpsUx/TargetScopeConcurrencyLimiterTest.php +- [X] T014 [P] [US1] Add hybrid selection identity hashing test in tests/Unit/Operations/BulkSelectionIdentityTest.php + +### Implementation for User Story 1 + +- [X] T015 [P] [US1] Add OperationRun context shape helpers for bulk runs in app/Support/OpsUx/BulkRunContext.php +- [X] T016 [P] [US1] Implement orchestrator job skeleton for bulk runs in app/Jobs/Operations/BulkOperationOrchestratorJob.php +- [X] T017 [P] [US1] Implement worker job skeleton for bulk items in app/Jobs/Operations/BulkOperationWorkerJob.php +- [X] T018 [US1] Ensure worker jobs update summary_counts via canonical whitelist in app/Services/OperationRunService.php + +**Decision (applies to all US1 migrations)**: Use the standard orchestrator + item worker pattern. +- Keep T016/T017 as the canonical implementation. +- Per-domain bulk logic MUST be implemented as item worker(s) invoked by the orchestrator. +- Avoid parallel legacy bulk job systems. + +- [X] T019 [US1] Migrate policy bulk delete start action to OperationRun-backed flow in app/Filament/Resources/PolicyResource.php +- [X] T020 [US1] Refactor policy bulk delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkPolicyDeleteJob.php + +- [X] T021 [US1] Migrate backup set bulk delete start action to OperationRun-backed flow in app/Filament/Resources/BackupSetResource.php +- [X] T022 [US1] Refactor backup set bulk delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkBackupSetDeleteJob.php + +- [X] T023 [US1] Migrate policy version prune start action to OperationRun-backed flow in app/Filament/Resources/PolicyVersionResource.php +- [X] T024 [US1] Refactor policy version prune execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkPolicyVersionPruneJob.php + +- [X] T025 [US1] Migrate policy version force delete start action to OperationRun-backed flow in app/Filament/Resources/PolicyVersionResource.php +- [X] T026 [US1] Refactor policy version force delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkPolicyVersionForceDeleteJob.php + +- [X] T027 [US1] Migrate restore run bulk delete start action to OperationRun-backed flow in app/Filament/Resources/RestoreRunResource.php +- [X] T028 [US1] Refactor restore run bulk delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkRestoreRunDeleteJob.php + +- [X] T029 [US1] Migrate tenant bulk sync start action to OperationRun-backed flow in app/Filament/Resources/TenantResource.php +- [X] T030 [US1] Refactor tenant bulk sync execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkTenantSyncJob.php + +- [X] T031 [US1] Migrate policy snapshot capture action to OperationRun-backed flow in app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php +- [X] T032 [US1] Refactor snapshot capture execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/CapturePolicySnapshotJob.php + +- [X] T033 [US1] Migrate drift generation flow to OperationRun-backed flow in app/Filament/Pages/DriftLanding.php +- [X] T034 [US1] Remove legacy BulkOperationRun coupling from drift generator job in app/Jobs/GenerateDriftFindingsJob.php + +- [X] T035 [US1] Remove legacy “fallback link” usage and standardize view-run URLs to canonical OperationRun links in app/Jobs/AddPoliciesToBackupSetJob.php + +**Checkpoint**: At this point, at least one representative bulk action is fully run-backed and visible in Monitoring + +--- + +## Phase 4: User Story 2 - Monitoring is the single source of run history (Priority: P2) + +**Goal**: No legacy “Bulk Operation Runs” surfaces exist; all view-run links route to Monitoring’s canonical run detail + +**Independent Test**: From any bulk action, “View run” navigates to OperationRun detail and there is no legacy BulkOperationRun resource. + +### Tests for User Story 2 + +- [X] T036 [P] [US2] Add regression test that notifications do not link to BulkOperationRun resources in tests/Feature/OpsUx/NotificationViewRunLinkTest.php + +### Implementation for User Story 2 + +- [X] T037 [US2] Replace BulkOperationRun resource links with OperationRun links in app/Notifications/RunStatusChangedNotification.php +- [X] T038 [US2] Replace BulkOperationRun resource links with OperationRun links in app/Filament/Resources/BackupSetResource.php +- [X] T039 [US2] Replace BulkOperationRun resource links with OperationRun links in app/Filament/Resources/PolicyVersionResource.php +- [X] T040 [US2] Replace BulkOperationRun resource links/redirects with OperationRun links in app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php + +- [X] T041 [US2] Remove legacy resource and pages: app/Filament/Resources/BulkOperationRunResource.php +- [X] T042 [US2] Remove legacy resource pages: app/Filament/Resources/BulkOperationRunResource/Pages/ListBulkOperationRuns.php +- [X] T043 [US2] Remove legacy resource pages: app/Filament/Resources/BulkOperationRunResource/Pages/ViewBulkOperationRun.php + +- [X] T044 [US2] Remove legacy authorization policy in app/Policies/BulkOperationRunPolicy.php +- [X] T045 [US2] Remove BulkOperationRun gate registration in app/Providers/AppServiceProvider.php + +**Checkpoint**: No navigation/link surfaces reference legacy bulk runs; Monitoring is the sole run history surface + +--- + +## Phase 5: User Story 3 - Developers can’t accidentally reintroduce legacy patterns (Priority: P3) + +**Goal**: Guardrails enforce single-run model and prevent UX drift / legacy reintroduction + +**Independent Test**: Introduce a forbidden legacy reference or bulk start surface without OperationRun and confirm automated tests fail. + +### Tests for User Story 3 + +- [X] T046 [P] [US3] Add “no legacy references” test guard in tests/Feature/Guards/NoLegacyBulkOperationsTest.php +- [X] T047 [P] [US3] Add tenant isolation guard for bulk enqueue inputs in tests/Feature/OpsUx/BulkTenantIsolationTest.php +- [X] T048 [P] [US3] Extend summary_counts whitelist coverage for bulk updates in tests/Feature/OpsUx/SummaryCountsWhitelistTest.php + +### Implementation for User Story 3 + +- [X] T049 [US3] Remove legacy BulkOperationRun unit tests in tests/Unit/BulkOperationRunStatusBucketTest.php +- [X] T050 [US3] Remove legacy BulkOperationRun unit tests in tests/Unit/BulkOperationRunProgressTest.php +- [X] T051 [US3] Remove legacy factory and update any dependent tests in database/factories/BulkOperationRunFactory.php +- [X] T052 [US3] Remove legacy test seeder and update any dependent docs/tests in database/seeders/BulkOperationsTestSeeder.php + +**Checkpoint**: Guardrails prevent reintroduction; test suite enforces taxonomy and canonical run surfaces + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final removal of legacy DB artifacts and cleanup + +- [X] T053 Create migration to drop legacy bulk_operation_runs table in database/migrations/2026_01_18_000001_drop_bulk_operation_runs_table.php +- [X] T054 Do NOT delete historical migrations; add forward drop migrations only + - Keep old migrations to support fresh installs and CI rebuilds + - Add a new forward migration: drop legacy bulk tables after cutover + - Document the cutover precondition (no new legacy writes) + +- [ ] T055 (optional) If schema history cleanup is required, use a documented squash/snapshot process + - Only via explicit procedure (not ad-hoc deletes) + - Must keep a reproducible schema for new environments +- [X] T056 Validate feature via targeted test run list and update notes in specs/056-remove-legacy-bulkops/quickstart.md + +--- + +## Monitoring DB-only render guard (NFR-01) + +- [X] T061 Add a regression test ensuring Monitoring → Operations pages do not invoke Graph/remote calls during render + - Approach: + - Mock/spy Graph client (or equivalent remote client) + - Render Operations index and OperationRun detail pages + - Assert no remote calls were made + - DoD: test fails if any Graph client is called from Monitoring render paths + +## Legacy removal (FR-006) + +- [X] T062 Remove BulkOperationRun model + related database artifacts (after cutover) + - Delete app/Models/BulkOperationRun.php (and any related models) + - Ensure no runtime references remain + +- [X] T063 Remove BulkOperationService and migrate all call sites to OperationRunService patterns + - Replace all uses of BulkOperationService::createRun(...) / dispatch flows + - Ensure all bulk actions create OperationRun and dispatch orchestrator/worker jobs + +- [X] T064 Add CI guard to prevent reintroduction of BulkOperationRun/BulkOperationService + - Grep/arch test: fail if repo contains BulkOperationRun or BulkOperationService + +## Target scope display (FR-008) + +- [X] T065 Update OperationRun run detail view to display target scope consistently + - Show entra_tenant_name if present, else show entra_tenant_id + - If directory_context_id exists, optionally show it as secondary info + - Ensure this is visible in Monitoring → Operations → Run Detail + - DoD: reviewers can start a run for a specific Entra tenant and see the target clearly on Run Detail + +## Failure semantics hardening (NFR-02) + +- [X] T066 Define/standardize reason codes for migrated bulk operations and enforce message sanitization bounds + - Baseline reason_code set: graph_throttled, graph_timeout, permission_denied, validation_error, conflict_detected, unknown_error + - Ensure reason_code is stable and machine-readable + - Ensure failure message is sanitized + bounded (no secrets/tokens/PII/raw payload dumps) + - DoD: for each new/migrated bulk operation type, expected reason_code usage is clear and consistent + +- [X] T067 Add a regression test asserting failures/notifications never persist secrets/PII + - Approach: create a run failure with sensitive-looking strings and assert persisted failures/notifications are sanitized + - DoD: test fails if sensitive patterns appear in stored failures/notifications + +## Remote retry/backoff/jitter policy (NFR-03) + +- [X] T068 Ensure migrated remote calls use the shared retry/backoff policy (429/503) and forbid ad-hoc retry loops + - Use bounded retries + exponential backoff with jitter + - DoD: no hand-rolled sleep/random retry logic in bulk workers; one test or assertion proves shared policy is used + +## Canonical “View run” sweep and guard (FR-005) + +- [X] T069 Perform a repo-wide sweep to ensure all “View run” links route to Monitoring → Operations → Run Detail + - Grep (or ripgrep if available) for legacy routes/resource URLs and legacy BulkOperationRun links + - Ensure links go through a canonical OperationRun URL helper (or equivalent single source) + - Optional: add a CI grep/guard forbidding known legacy route names/URLs + - DoD: documented check/list shows no legacy “View run” links remain + +--- + +## Adapter run reconciliation (NFR-04) + +- [X] T070 Add DB-only adapter run reconciler in app/Services/AdapterRunReconciler.php +- [X] T071 Implement ops:reconcile-adapter-runs command in app/Console/Commands/OpsReconcileAdapterRuns.php +- [X] T072 Implement scheduled reconciliation job + scheduler wiring in app/Jobs/ReconcileAdapterRunsJob.php and routes/console.php +- [X] T073 Add AdapterRunReconciler tests in tests/Feature/OpsUx/AdapterRunReconcilerTest.php + +--- + +## Dependencies & Execution Order + +### User Story Dependencies + +- **US1 (P1)**: Foundation for cutover; must complete before deleting legacy UI/DB. +- **US2 (P2)**: Depends on US1 (cutover) so links can be fully canonicalized. +- **US3 (P3)**: Can start after Foundational and run in parallel with US2, but should be finalized after US1 cutover. + +### Parallel Opportunities + +- Tasks marked **[P]** are safe to do in parallel (new files or isolated edits). +- Within US1, jobs and Filament start-surface migrations can be split by resource (Policies vs BackupSets vs PolicyVersions vs RestoreRuns). + +--- + +## Parallel Example: User Story 1 + +- Task: T012 [US1] tests/Feature/OpsUx/BulkEnqueueIdempotencyTest.php +- Task: T013 [US1] tests/Feature/OpsUx/TargetScopeConcurrencyLimiterTest.php +- Task: T014 [US1] tests/Unit/Operations/BulkSelectionIdentityTest.php + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Setup + Foundational +2. Complete US1 migrations for at least one representative bulk action (end-to-end) +3. Validate Monitoring visibility + queued toast + terminal notification + +### Incremental Delivery + +- Migrate bulk workflows in small, independently testable slices (one resource at a time) while keeping Monitoring canonical. +- Remove legacy surfaces only after all start surfaces are migrated. diff --git a/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php index 2157638..f0b43de 100644 --- a/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php +++ b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php @@ -4,7 +4,6 @@ use App\Models\BackupSchedule; use App\Models\BackupScheduleRun; use App\Models\BackupSet; -use App\Services\BulkOperationService; use App\Services\Intune\BackupService; use App\Services\Intune\PolicySyncService; use App\Services\OperationRunService; @@ -75,14 +74,13 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, Cache::flush(); - (new RunBackupScheduleJob($run->id, null, $operationRun))->handle( + (new RunBackupScheduleJob($run->id, $operationRun))->handle( app(PolicySyncService::class), app(BackupService::class), app(\App\Services\BackupScheduling\PolicyTypeResolver::class), app(\App\Services\BackupScheduling\ScheduleTimeService::class), app(\App\Services\Intune\AuditLogger::class), app(\App\Services\BackupScheduling\RunErrorMapper::class), - app(BulkOperationService::class), ); $run->refresh(); @@ -92,11 +90,14 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, $operationRun->refresh(); expect($operationRun->status)->toBe('completed'); expect($operationRun->outcome)->toBe('succeeded'); - expect($operationRun->summary_counts)->toMatchArray([ + expect($operationRun->context)->toMatchArray([ 'backup_schedule_id' => (int) $schedule->id, 'backup_schedule_run_id' => (int) $run->id, 'backup_set_id' => (int) $backupSet->id, ]); + expect($operationRun->summary_counts)->toMatchArray([ + 'created' => 1, + ]); }); it('skips runs when all policy types are unknown', function () { @@ -137,14 +138,13 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, initiator: $user, ); - (new RunBackupScheduleJob($run->id, null, $operationRun))->handle( + (new RunBackupScheduleJob($run->id, $operationRun))->handle( app(PolicySyncService::class), app(BackupService::class), app(\App\Services\BackupScheduling\PolicyTypeResolver::class), app(\App\Services\BackupScheduling\ScheduleTimeService::class), app(\App\Services\Intune\AuditLogger::class), app(\App\Services\BackupScheduling\RunErrorMapper::class), - app(BulkOperationService::class), ); $run->refresh(); @@ -156,7 +156,7 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, expect($operationRun->status)->toBe('completed'); expect($operationRun->outcome)->toBe('failed'); expect($operationRun->failure_summary)->toMatchArray([ - ['code' => 'unknown_policy_type', 'message' => $run->error_message], + ['code' => 'unknown_policy_type', 'message' => $run->error_message, 'reason_code' => 'unknown_error'], ]); }); @@ -237,15 +237,16 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, app(\App\Services\BackupScheduling\ScheduleTimeService::class), app(\App\Services\Intune\AuditLogger::class), app(\App\Services\BackupScheduling\RunErrorMapper::class), - app(BulkOperationService::class), ); $operationRun->refresh(); expect($operationRun->status)->toBe('completed'); expect($operationRun->outcome)->toBe('succeeded'); - expect($operationRun->summary_counts)->toMatchArray([ - 'backup_schedule_id' => (int) $schedule->id, + expect($operationRun->context)->toMatchArray([ 'backup_schedule_run_id' => (int) $run->id, 'backup_set_id' => (int) $backupSet->id, ]); + expect($operationRun->summary_counts)->toMatchArray([ + 'created' => 1, + ]); }); diff --git a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php index 02876a6..d5c8d5c 100644 --- a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php +++ b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php @@ -4,7 +4,6 @@ use App\Jobs\RunBackupScheduleJob; use App\Models\BackupSchedule; use App\Models\BackupScheduleRun; -use App\Models\BulkOperationRun; use App\Models\OperationRun; use App\Models\User; use App\Notifications\OperationRunQueued; @@ -68,14 +67,6 @@ 'backup_schedule_run_id' => (int) $run->id, ]); - expect(BulkOperationRun::query() - ->where('tenant_id', $tenant->id) - ->where('user_id', $user->id) - ->where('resource', 'backup_schedule') - ->where('action', 'run') - ->count()) - ->toBe(1); - Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool { return $job->backupScheduleRunId === (int) $run->id && $job->operationRun instanceof OperationRun @@ -139,14 +130,6 @@ 'backup_schedule_run_id' => (int) $run->id, ]); - expect(BulkOperationRun::query() - ->where('tenant_id', $tenant->id) - ->where('user_id', $user->id) - ->where('resource', 'backup_schedule') - ->where('action', 'retry') - ->count()) - ->toBe(1); - Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool { return $job->backupScheduleRunId === (int) $run->id && $job->operationRun instanceof OperationRun @@ -259,14 +242,6 @@ ->count()) ->toBe(2); - expect(BulkOperationRun::query() - ->where('tenant_id', $tenant->id) - ->where('user_id', $user->id) - ->where('resource', 'backup_schedule') - ->where('action', 'run') - ->count()) - ->toBe(1); - Queue::assertPushed(RunBackupScheduleJob::class, 2); $this->assertDatabaseCount('notifications', 1); $this->assertDatabaseHas('notifications', [ @@ -330,14 +305,6 @@ ->count()) ->toBe(2); - expect(BulkOperationRun::query() - ->where('tenant_id', $tenant->id) - ->where('user_id', $user->id) - ->where('resource', 'backup_schedule') - ->where('action', 'retry') - ->count()) - ->toBe(1); - Queue::assertPushed(RunBackupScheduleJob::class, 2); $this->assertDatabaseCount('notifications', 1); $this->assertDatabaseHas('notifications', [ diff --git a/tests/Feature/BackupSets/AddPoliciesToBackupSetJobTest.php b/tests/Feature/BackupSets/AddPoliciesToBackupSetJobTest.php index 511e76c..d9c6092 100644 --- a/tests/Feature/BackupSets/AddPoliciesToBackupSetJobTest.php +++ b/tests/Feature/BackupSets/AddPoliciesToBackupSetJobTest.php @@ -3,13 +3,13 @@ use App\Jobs\AddPoliciesToBackupSetJob; use App\Models\BackupItem; use App\Models\BackupSet; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\Policy; use App\Models\PolicyVersion; -use App\Services\BulkOperationService; use App\Services\Intune\FoundationSnapshotService; use App\Services\Intune\PolicyCaptureOrchestrator; use App\Services\Intune\SnapshotValidator; +use App\Services\OperationRunService; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery\MockInterface; @@ -44,23 +44,19 @@ 'snapshot' => ['id' => $policyA->external_id], ]); - $run = BulkOperationRun::factory()->create([ + $run = OperationRun::factory()->create([ 'tenant_id' => $tenant->id, 'user_id' => $user->id, - 'resource' => 'backup_set', - 'action' => 'add_policies', - 'status' => 'pending', - 'total_items' => 2, - 'item_ids' => [ - 'backup_set_id' => $backupSet->id, - 'policy_ids' => [$policyA->id, $policyB->id], - 'options' => [ - 'include_assignments' => true, - 'include_scope_tags' => true, - 'include_foundations' => false, - ], + 'initiator_name' => $user->name, + 'type' => 'backup_set.add_policies', + 'status' => 'queued', + 'outcome' => 'pending', + 'context' => [ + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_ids' => [(int) $policyA->getKey(), (int) $policyB->getKey()], ], - 'failures' => [], + 'summary_counts' => [], + 'failure_summary' => [], ]); $this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($policyA, $policyB, $tenant, $versionA) { @@ -107,15 +103,21 @@ }); $job = new AddPoliciesToBackupSetJob( - bulkRunId: (int) $run->getKey(), + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), backupSetId: (int) $backupSet->getKey(), - includeAssignments: true, - includeScopeTags: true, - includeFoundations: false, + policyIds: [(int) $policyA->getKey(), (int) $policyB->getKey()], + options: [ + 'include_assignments' => true, + 'include_scope_tags' => true, + 'include_foundations' => false, + ], + idempotencyKey: 'test-idempotency-key', + operationRun: $run, ); $job->handle( - bulkOperationService: app(BulkOperationService::class), + operationRunService: app(OperationRunService::class), captureOrchestrator: app(PolicyCaptureOrchestrator::class), foundationSnapshots: $this->mock(FoundationSnapshotService::class), snapshotValidator: app(SnapshotValidator::class), @@ -124,23 +126,23 @@ $run->refresh(); $backupSet->refresh(); - expect($run->status)->toBe('completed_with_errors'); - expect($run->total_items)->toBe(2); - expect($run->processed_items)->toBe(2); - expect($run->succeeded)->toBe(1); - expect($run->failed)->toBe(1); - expect($run->skipped)->toBe(0); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('partially_succeeded'); + expect((int) ($run->summary_counts['total'] ?? 0))->toBe(2); + expect((int) ($run->summary_counts['processed'] ?? 0))->toBe(2); + expect((int) ($run->summary_counts['succeeded'] ?? 0))->toBe(1); + expect((int) ($run->summary_counts['failed'] ?? 0))->toBe(1); + expect((int) ($run->summary_counts['skipped'] ?? 0))->toBe(0); expect(BackupItem::query() ->where('backup_set_id', $backupSet->id) ->where('policy_id', $policyA->id) ->exists())->toBeTrue(); - $failureEntry = collect($run->failures ?? []) - ->firstWhere('item_id', (string) $policyB->id); + $failureEntry = collect($run->failure_summary ?? []) + ->first(fn ($entry): bool => is_array($entry) && (($entry['code'] ?? null) === 'graph.graph_forbidden')); expect($failureEntry)->not->toBeNull(); - expect($failureEntry['reason_code'] ?? null)->toBe('graph_forbidden'); expect($backupSet->status)->toBe('partial'); }); diff --git a/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php b/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php index 7ecdb52..542ee83 100644 --- a/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php +++ b/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php @@ -4,7 +4,6 @@ use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\OperationRun; -use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Support\OperationRunLinks; use Filament\Notifications\DatabaseNotification; @@ -50,7 +49,7 @@ operationRun: $opRun, ); - $job->handle(app(AuditLogger::class), app(BulkOperationService::class)); + $job->handle(app(AuditLogger::class)); $this->assertDatabaseHas('notifications', [ 'notifiable_id' => $user->getKey(), diff --git a/tests/Feature/BulkDeleteBackupSetsTest.php b/tests/Feature/BulkDeleteBackupSetsTest.php index b3d81bd..9b0e7cb 100644 --- a/tests/Feature/BulkDeleteBackupSetsTest.php +++ b/tests/Feature/BulkDeleteBackupSetsTest.php @@ -3,7 +3,7 @@ use App\Filament\Resources\BackupSetResource; use App\Models\BackupItem; use App\Models\BackupSet; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; @@ -51,14 +51,15 @@ $sets->each(fn (BackupSet $set) => expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue()); - $bulkRun = BulkOperationRun::query() - ->where('resource', 'backup_set') - ->where('action', 'delete') + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('type', 'backup_set.delete') ->latest('id') ->first(); - expect($bulkRun)->not->toBeNull(); - expect($bulkRun->status)->toBe('completed'); + expect($opRun)->not->toBeNull(); + expect($opRun->status)->toBe('completed'); }); test('backup sets can be archived even when referenced by restore runs', function () { diff --git a/tests/Feature/BulkDeleteMixedStatusTest.php b/tests/Feature/BulkDeleteMixedStatusTest.php index 1a35e66..5969c39 100644 --- a/tests/Feature/BulkDeleteMixedStatusTest.php +++ b/tests/Feature/BulkDeleteMixedStatusTest.php @@ -2,7 +2,7 @@ use App\Filament\Resources\RestoreRunResource; use App\Models\BackupSet; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; @@ -56,12 +56,14 @@ $completedRuns->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue()); expect(RestoreRun::withTrashed()->find($running->id)?->trashed())->toBeFalse(); - $bulkRun = BulkOperationRun::query() - ->where('resource', 'restore_run') - ->where('action', 'delete') + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('type', 'restore_run.delete') ->latest('id') ->first(); - expect($bulkRun)->not->toBeNull(); - expect($bulkRun->skipped)->toBeGreaterThanOrEqual(1); + expect($opRun)->not->toBeNull(); + $counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : []; + expect((int) ($counts['skipped'] ?? 0))->toBeGreaterThanOrEqual(1); }); diff --git a/tests/Feature/BulkDeletePoliciesAsyncTest.php b/tests/Feature/BulkDeletePoliciesAsyncTest.php index 754b094..62f7827 100644 --- a/tests/Feature/BulkDeletePoliciesAsyncTest.php +++ b/tests/Feature/BulkDeletePoliciesAsyncTest.php @@ -4,7 +4,7 @@ use App\Models\Policy; use App\Models\Tenant; use App\Models\User; -use App\Services\BulkOperationService; +use App\Services\OperationRunService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; @@ -18,13 +18,31 @@ $policies = Policy::factory()->count(25)->create(['tenant_id' => $tenant->id]); $policyIds = $policies->pluck('id')->toArray(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 25); + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + $opRun = $service->ensureRun( + tenant: $tenant, + type: 'policy.delete', + inputs: [ + 'scope' => 'subset', + 'policy_ids' => $policyIds, + ], + initiator: $user, + ); // Simulate Async dispatch (this logic will be in Filament Action) - BulkPolicyDeleteJob::dispatch($run->id); + BulkPolicyDeleteJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $policyIds, + operationRun: $opRun, + ); - Queue::assertPushed(BulkPolicyDeleteJob::class, function ($job) use ($run) { - return $job->bulkRunId === $run->id; + Queue::assertPushed(BulkPolicyDeleteJob::class, function ($job) use ($tenant, $user, $opRun, $policyIds) { + return $job->tenantId === (int) $tenant->getKey() + && $job->userId === (int) $user->getKey() + && $job->operationRun?->getKey() === $opRun->getKey() + && $job->policyIds === $policyIds; }); }); diff --git a/tests/Feature/BulkDeletePoliciesTest.php b/tests/Feature/BulkDeletePoliciesTest.php index 4a2c72f..97e0758 100644 --- a/tests/Feature/BulkDeletePoliciesTest.php +++ b/tests/Feature/BulkDeletePoliciesTest.php @@ -1,10 +1,12 @@ count(10)->create(['tenant_id' => $tenant->id]); $policyIds = $policies->pluck('id')->toArray(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 10); + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); - // Simulate Sync execution - BulkPolicyDeleteJob::dispatchSync($run->id); + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); - $run->refresh(); - expect($run->status)->toBe('completed') - ->and($run->processed_items)->toBe(10) - ->and($run->audit_log_id)->not->toBeNull(); + $selectionIdentity = $selection->fromIds($policyIds); - expect(\App\Models\AuditLog::where('action', 'bulk.policy.delete.completed')->exists())->toBeTrue(); + $opRun = $service->enqueueBulkOperation( + tenant: $tenant, + type: 'policy.delete', + targetScope: [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id ?? $tenant->getKey()), + ], + selectionIdentity: $selectionIdentity, + dispatcher: function ($operationRun) use ($tenant, $user, $policyIds): void { + // Simulate sync execution (workers will run immediately on sync queue) + BulkPolicyDeleteJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $policyIds, + operationRun: $operationRun, + ); + }, + initiator: $user, + emitQueuedNotification: false, + ); + + $opRun->refresh(); + expect($opRun)->toBeInstanceOf(OperationRun::class); + expect($opRun->status)->toBe('completed'); + expect($opRun->outcome)->toBe('succeeded'); + expect($opRun->summary_counts)->toMatchArray([ + 'total' => 10, + 'processed' => 10, + 'succeeded' => 10, + ]); + + expect(($opRun->summary_counts['failed'] ?? 0))->toBe(0); $policies->each(function ($policy) { expect($policy->refresh()->ignored_at)->not->toBeNull(); diff --git a/tests/Feature/BulkDeleteRestoreRunsTest.php b/tests/Feature/BulkDeleteRestoreRunsTest.php index 3ad115c..2bb1e10 100644 --- a/tests/Feature/BulkDeleteRestoreRunsTest.php +++ b/tests/Feature/BulkDeleteRestoreRunsTest.php @@ -2,7 +2,7 @@ use App\Filament\Resources\RestoreRunResource; use App\Models\BackupSet; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; @@ -45,12 +45,13 @@ $runs->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue()); - $bulkRun = BulkOperationRun::query() - ->where('resource', 'restore_run') - ->where('action', 'delete') + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('type', 'restore_run.delete') ->latest('id') ->first(); - expect($bulkRun)->not->toBeNull(); - expect($bulkRun->status)->toBe('completed'); + expect($opRun)->not->toBeNull(); + expect($opRun->status)->toBe('completed'); }); diff --git a/tests/Feature/BulkExportFailuresTest.php b/tests/Feature/BulkExportFailuresTest.php index 3fd0cf7..d6e76b1 100644 --- a/tests/Feature/BulkExportFailuresTest.php +++ b/tests/Feature/BulkExportFailuresTest.php @@ -1,11 +1,12 @@ create(['tenant_id' => $tenant->id]); - $service = app(BulkOperationService::class); - $run = $service->createRun( - $tenant, - $user, - 'policy', - 'export', - [$okPolicy->id, $missingVersionPolicy->id], - 2 + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + $opRun = $service->ensureRun( + tenant: $tenant, + type: 'policy.export', + inputs: [ + 'scope' => 'subset', + 'policy_ids' => [$okPolicy->id, $missingVersionPolicy->id], + ], + initiator: $user, ); - (new BulkPolicyExportJob($run->id, 'Failures Backup'))->handle($service); + (new BulkPolicyExportJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: [$okPolicy->id, $missingVersionPolicy->id], + backupName: 'Failures Backup', + operationRun: $opRun, + ))->handle($service); - $run->refresh(); - expect($run->status)->toBe('completed_with_errors') - ->and($run->succeeded)->toBe(1) - ->and($run->failed)->toBe(1) - ->and($run->processed_items)->toBe(2); + $opRun->refresh(); + expect($opRun)->toBeInstanceOf(OperationRun::class); + expect($opRun->status)->toBe('completed'); + expect($opRun->outcome)->toBe('partially_succeeded'); + expect($opRun->summary_counts)->toMatchArray([ + 'total' => 2, + 'processed' => 2, + 'succeeded' => 1, + 'failed' => 1, + 'created' => 1, + ]); $this->assertDatabaseHas('backup_sets', [ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/BulkExportToBackupTest.php b/tests/Feature/BulkExportToBackupTest.php index e315aea..e4c5ee8 100644 --- a/tests/Feature/BulkExportToBackupTest.php +++ b/tests/Feature/BulkExportToBackupTest.php @@ -1,11 +1,12 @@ now(), ]); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'export', [$policy->id], 1); + $opRun = OperationRun::create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'initiator_name' => $user->name, + 'type' => 'policy.export', + 'status' => 'queued', + 'outcome' => 'pending', + 'run_identity_hash' => 'policy-export-test', + 'context' => [ + 'policy_ids' => [$policy->id], + 'backup_name' => 'Feature Backup', + ], + ]); // Simulate Sync - $job = new BulkPolicyExportJob($run->id, 'Feature Backup'); - $job->handle($service); + $job = new BulkPolicyExportJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: [$policy->id], + backupName: 'Feature Backup', + backupDescription: null, + operationRun: $opRun, + ); + $job->handle(app(OperationRunService::class)); - $run->refresh(); - expect($run->status)->toBe('completed'); + $opRun->refresh(); + expect($opRun->status)->toBe('completed'); $this->assertDatabaseHas('backup_sets', [ 'name' => 'Feature Backup', diff --git a/tests/Feature/BulkForceDeleteBackupSetsTest.php b/tests/Feature/BulkForceDeleteBackupSetsTest.php index abc6316..085b90f 100644 --- a/tests/Feature/BulkForceDeleteBackupSetsTest.php +++ b/tests/Feature/BulkForceDeleteBackupSetsTest.php @@ -3,7 +3,7 @@ use App\Filament\Resources\BackupSetResource; use App\Models\BackupItem; use App\Models\BackupSet; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; use Filament\Facades\Filament; @@ -50,12 +50,13 @@ expect(BackupSet::withTrashed()->find($set->id))->toBeNull(); expect(BackupItem::withTrashed()->find($item->id))->toBeNull(); - $bulkRun = BulkOperationRun::query() - ->where('resource', 'backup_set') - ->where('action', 'force_delete') + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('type', 'backup_set.force_delete') ->latest('id') ->first(); - expect($bulkRun)->not->toBeNull(); - expect($bulkRun->status)->toBe('completed'); + expect($opRun)->not->toBeNull(); + expect($opRun->status)->toBe('completed'); }); diff --git a/tests/Feature/BulkForceDeletePolicyVersionsTest.php b/tests/Feature/BulkForceDeletePolicyVersionsTest.php index 701bd44..d4dae8f 100644 --- a/tests/Feature/BulkForceDeletePolicyVersionsTest.php +++ b/tests/Feature/BulkForceDeletePolicyVersionsTest.php @@ -1,7 +1,7 @@ assertHasNoTableBulkActionErrors(); - $run = BulkOperationRun::query() + $run = OperationRun::query() ->where('tenant_id', $tenant->id) ->where('user_id', $user->id) - ->where('resource', 'policy_version') - ->where('action', 'force_delete') + ->where('type', 'policy_version.force_delete') ->latest('id') ->first(); expect($run)->not->toBeNull(); - expect($run->succeeded)->toBe(1) - ->and($run->skipped)->toBe(0) - ->and($run->failed)->toBe(0); + + $counts = is_array($run->summary_counts) ? $run->summary_counts : []; + expect((int) ($counts['succeeded'] ?? 0))->toBe(1) + ->and((int) ($counts['skipped'] ?? 0))->toBe(0) + ->and((int) ($counts['failed'] ?? 0))->toBe(0); expect(PolicyVersion::withTrashed()->whereKey($version->id)->exists())->toBeFalse(); }); diff --git a/tests/Feature/BulkForceDeleteRestoreRunsTest.php b/tests/Feature/BulkForceDeleteRestoreRunsTest.php index cd383d8..0cb0442 100644 --- a/tests/Feature/BulkForceDeleteRestoreRunsTest.php +++ b/tests/Feature/BulkForceDeleteRestoreRunsTest.php @@ -2,7 +2,7 @@ use App\Filament\Resources\RestoreRunResource; use App\Models\BackupSet; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; @@ -52,12 +52,14 @@ $runs->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id))->toBeNull()); - $bulkRun = BulkOperationRun::query() - ->where('resource', 'restore_run') - ->where('action', 'force_delete') + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'restore_run.force_delete') ->latest('id') ->first(); - expect($bulkRun)->not->toBeNull(); - expect($bulkRun->status)->toBe('completed'); + expect($opRun)->not->toBeNull(); + expect($opRun->status)->toBe('completed'); + expect($opRun->outcome)->toBeIn(['succeeded', 'partially_succeeded']); + expect((int) ($opRun->summary_counts['deleted'] ?? 0))->toBe(3); }); diff --git a/tests/Feature/BulkProgressNotificationTest.php b/tests/Feature/BulkProgressNotificationTest.php index 0f345ec..bd95eff 100644 --- a/tests/Feature/BulkProgressNotificationTest.php +++ b/tests/Feature/BulkProgressNotificationTest.php @@ -1,105 +1,64 @@ create(); - $tenant->makeCurrent(); - $user = User::factory()->create(); + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + Filament::setTenant($tenant, true); - // Own running op - BulkOperationRun::factory()->create([ + // Active op + OperationRun::factory()->create([ 'tenant_id' => $tenant->id, 'user_id' => $user->id, + 'initiator_name' => $user->name, + 'type' => 'policy.delete', 'status' => 'running', - 'resource' => 'policy', - 'action' => 'delete', - 'total_items' => 100, - 'processed_items' => 50, + 'outcome' => 'pending', + 'context' => ['scope' => 'subset'], ]); // Completed op (should not show) - BulkOperationRun::factory()->create([ + OperationRun::factory()->create([ 'tenant_id' => $tenant->id, 'user_id' => $user->id, + 'initiator_name' => $user->name, + 'type' => 'policy.delete', 'status' => 'completed', + 'outcome' => 'succeeded', 'updated_at' => now()->subMinutes(5), ]); - // Other user's op (should not show) - $otherUser = User::factory()->create(); - BulkOperationRun::factory()->create([ - 'tenant_id' => $tenant->id, - 'user_id' => $otherUser->id, - 'status' => 'running', - ]); - - auth()->login($user); // Login user explicitly for auth()->id() call in component - Livewire::actingAs($user) ->test(BulkOperationProgress::class) - ->assertSee('Delete Policy') - ->assertSee('50 / 100'); + ->assertSee('Delete policies') + ->assertDontSee('Unknown operation'); }); -test('progress widget reconciles stale pending backup schedule runs', function () { - $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); - $user = User::factory()->create(); +test('progress widget shows queued backup schedule runs as operation runs', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + Filament::setTenant($tenant, true); - $schedule = BackupSchedule::query()->create([ - 'tenant_id' => $tenant->id, - 'name' => 'Nightly', - 'is_enabled' => true, - 'timezone' => 'UTC', - 'frequency' => 'daily', - 'time_of_day' => '01:00:00', - 'days_of_week' => null, - 'policy_types' => ['deviceConfiguration'], - 'include_foundations' => true, - 'retention_keep_last' => 30, - 'next_run_at' => now()->addHour(), - ]); - - $bulkRun = BulkOperationRun::factory()->create([ + OperationRun::factory()->create([ 'tenant_id' => $tenant->id, 'user_id' => $user->id, - 'status' => 'pending', - 'resource' => 'backup_schedule', - 'action' => 'run', - 'total_items' => 1, - 'processed_items' => 0, - 'item_ids' => [(string) $schedule->id], + 'initiator_name' => $user->name, + 'type' => 'backup_schedule.run_now', + 'status' => 'queued', + 'outcome' => 'pending', + 'context' => ['scope' => 'scheduled'], 'created_at' => now()->subMinutes(2), 'updated_at' => now()->subMinutes(2), ]); - BackupScheduleRun::query()->create([ - 'backup_schedule_id' => $schedule->id, - 'tenant_id' => $tenant->id, - 'user_id' => $user->id, - 'scheduled_for' => now()->startOfMinute(), - 'started_at' => now()->subMinute(), - 'finished_at' => now(), - 'status' => BackupScheduleRun::STATUS_SUCCESS, - 'summary' => null, - ]); - - auth()->login($user); - Livewire::actingAs($user) ->test(BulkOperationProgress::class) - ->assertSee('Run Backup schedule') - ->assertSee('1 / 1'); - - expect($bulkRun->refresh()->status)->toBe('completed'); + ->assertSee('Backup schedule run'); }); diff --git a/tests/Feature/BulkPruneSkipReasonsTest.php b/tests/Feature/BulkPruneSkipReasonsTest.php index de35dcb..97fc3fa 100644 --- a/tests/Feature/BulkPruneSkipReasonsTest.php +++ b/tests/Feature/BulkPruneSkipReasonsTest.php @@ -1,7 +1,7 @@ assertHasNoTableBulkActionErrors(); - $run = BulkOperationRun::query() + $run = OperationRun::query() ->where('tenant_id', $tenant->id) ->where('user_id', $user->id) - ->where('resource', 'policy_version') - ->where('action', 'prune') + ->where('type', 'policy_version.prune') ->latest('id') ->first(); expect($run)->not->toBeNull(); - $reasons = collect($run->failures ?? [])->pluck('reason')->all(); - expect($reasons)->toContain('Current version') - ->and($reasons)->toContain('Too recent'); + + $counts = is_array($run->summary_counts) ? $run->summary_counts : []; + expect((int) ($counts['processed'] ?? 0))->toBe(2); + expect((int) ($counts['skipped'] ?? 0))->toBe(2); }); diff --git a/tests/Feature/BulkRestoreBackupSetsTest.php b/tests/Feature/BulkRestoreBackupSetsTest.php index 6132119..2d4a62d 100644 --- a/tests/Feature/BulkRestoreBackupSetsTest.php +++ b/tests/Feature/BulkRestoreBackupSetsTest.php @@ -3,7 +3,7 @@ use App\Filament\Resources\BackupSetResource; use App\Models\BackupItem; use App\Models\BackupSet; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; use Filament\Facades\Filament; @@ -53,12 +53,13 @@ expect($set->trashed())->toBeFalse(); expect($item->trashed())->toBeFalse(); - $bulkRun = BulkOperationRun::query() - ->where('resource', 'backup_set') - ->where('action', 'restore') + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('type', 'backup_set.restore') ->latest('id') ->first(); - expect($bulkRun)->not->toBeNull(); - expect($bulkRun->status)->toBe('completed'); + expect($opRun)->not->toBeNull(); + expect($opRun->status)->toBe('completed'); }); diff --git a/tests/Feature/BulkRestorePolicyVersionsTest.php b/tests/Feature/BulkRestorePolicyVersionsTest.php index 8f3b429..fdab5fe 100644 --- a/tests/Feature/BulkRestorePolicyVersionsTest.php +++ b/tests/Feature/BulkRestorePolicyVersionsTest.php @@ -1,7 +1,7 @@ callTableBulkAction('bulk_restore_versions', collect([$version])) ->assertHasNoTableBulkActionErrors(); - $run = BulkOperationRun::query() + $run = OperationRun::query() ->where('tenant_id', $tenant->id) ->where('user_id', $user->id) - ->where('resource', 'policy_version') - ->where('action', 'restore') + ->where('type', 'policy_version.restore') ->latest('id') ->first(); expect($run)->not->toBeNull(); - expect($run->succeeded)->toBe(1) - ->and($run->skipped)->toBe(0) - ->and($run->failed)->toBe(0); + + $counts = is_array($run->summary_counts) ? $run->summary_counts : []; + expect((int) ($counts['succeeded'] ?? 0))->toBe(1) + ->and((int) ($counts['skipped'] ?? 0))->toBe(0) + ->and((int) ($counts['failed'] ?? 0))->toBe(0); $version->refresh(); expect($version->trashed())->toBeFalse(); diff --git a/tests/Feature/BulkRestoreRestoreRunsTest.php b/tests/Feature/BulkRestoreRestoreRunsTest.php index a6fa5e9..ab2cf8f 100644 --- a/tests/Feature/BulkRestoreRestoreRunsTest.php +++ b/tests/Feature/BulkRestoreRestoreRunsTest.php @@ -2,7 +2,7 @@ use App\Filament\Resources\RestoreRunResource; use App\Models\BackupSet; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; @@ -45,18 +45,19 @@ ->callTableBulkAction('bulk_restore', collect([$run])) ->assertHasNoTableBulkActionErrors(); - $bulkRun = BulkOperationRun::query() + $opRun = OperationRun::query() ->where('tenant_id', $tenant->id) ->where('user_id', $user->id) - ->where('resource', 'restore_run') - ->where('action', 'restore') + ->where('type', 'restore_run.restore') ->latest('id') ->first(); - expect($bulkRun)->not->toBeNull(); - expect($bulkRun->succeeded)->toBe(1) - ->and($bulkRun->skipped)->toBe(0) - ->and($bulkRun->failed)->toBe(0); + expect($opRun)->not->toBeNull(); + + $counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : []; + expect((int) ($counts['succeeded'] ?? 0))->toBe(1) + ->and((int) ($counts['skipped'] ?? 0))->toBe(0) + ->and((int) ($counts['failed'] ?? 0))->toBe(0); $run->refresh(); expect($run->trashed())->toBeFalse(); diff --git a/tests/Feature/BulkSyncPoliciesTest.php b/tests/Feature/BulkSyncPoliciesTest.php index 6e976e0..74b990b 100644 --- a/tests/Feature/BulkSyncPoliciesTest.php +++ b/tests/Feature/BulkSyncPoliciesTest.php @@ -1,22 +1,20 @@ create(); +test('policy sync updates selected policies from graph and updates the operation run', function () { + $tenant = Tenant::factory()->create([ + 'status' => 'active', + ]); $tenant->makeCurrent(); - $user = User::factory()->create(); $policies = Policy::factory() ->count(3) @@ -25,6 +23,7 @@ 'policy_type' => 'deviceConfiguration', 'platform' => 'windows10AndLater', 'last_synced_at' => null, + 'ignored_at' => null, ]); app()->instance(GraphClientInterface::class, new class implements GraphClientInterface @@ -67,17 +66,44 @@ public function request(string $method, string $path, array $options = []): Grap } }); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'sync', $policies->modelKeys(), 3); + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); - BulkPolicySyncJob::dispatchSync($run->id); + $selectedIds = $policies + ->pluck('id') + ->map(static fn ($id): int => (int) $id) + ->sort() + ->values() + ->all(); - $bulkRun = BulkOperationRun::query()->find($run->id); - expect($bulkRun)->not->toBeNull(); - expect($bulkRun->status)->toBe('completed'); - expect($bulkRun->total_items)->toBe(3); - expect($bulkRun->succeeded)->toBe(3); - expect($bulkRun->failed)->toBe(0); + $opRun = $runs->ensureRun( + tenant: $tenant, + type: 'policy.sync', + inputs: [ + 'scope' => 'subset', + 'policy_ids' => $selectedIds, + ], + initiator: null, + ); + + SyncPoliciesJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + types: null, + policyIds: $selectedIds, + operationRun: $opRun, + ); + + $opRun->refresh(); + + expect($opRun->status)->toBe('completed'); + expect($opRun->outcome)->toBe('succeeded'); + expect($opRun->summary_counts)->toMatchArray([ + 'total' => 3, + 'processed' => 3, + 'succeeded' => 3, + 'failed' => 0, + 'skipped' => 0, + ]); $policies->each(function (Policy $policy) { $policy->refresh(); @@ -88,6 +114,4 @@ public function request(string $method, string $path, array $options = []): Grap 'example' => 'value', ]); }); - - expect(AuditLog::where('action', 'bulk.policy.sync.completed')->exists())->toBeTrue(); }); diff --git a/tests/Feature/BulkUnignorePoliciesTest.php b/tests/Feature/BulkUnignorePoliciesTest.php index fa53b64..29e029b 100644 --- a/tests/Feature/BulkUnignorePoliciesTest.php +++ b/tests/Feature/BulkUnignorePoliciesTest.php @@ -1,10 +1,11 @@ pluck('id')->toArray(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'unignore', $policyIds, count($policyIds)); + $opRun = OperationRun::create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'initiator_name' => $user->name, + 'type' => 'policy.unignore', + 'status' => 'queued', + 'outcome' => 'pending', + 'run_identity_hash' => 'policy-unignore-test', + 'context' => [ + 'policy_ids' => $policyIds, + ], + ]); - BulkPolicyUnignoreJob::dispatchSync($run->id); + BulkPolicyUnignoreJob::dispatchSync( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $policyIds, + operationRun: $opRun, + ); - $run->refresh(); + $opRun->refresh(); - expect($run->status)->toBe('completed') - ->and($run->processed_items)->toBe(5) - ->and($run->audit_log_id)->not->toBeNull(); + expect($opRun->status)->toBe('completed'); - expect(\App\Models\AuditLog::where('action', 'bulk.policy.unignore.completed')->exists())->toBeTrue(); + $counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : []; + expect((int) ($counts['processed'] ?? 0))->toBe(5); + expect((int) ($counts['succeeded'] ?? 0))->toBe(5); + expect((int) ($counts['failed'] ?? 0))->toBe(0); $policies->each(function (Policy $policy): void { expect($policy->refresh()->ignored_at)->toBeNull(); diff --git a/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php b/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php index 40192e5..d9be64d 100644 --- a/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php +++ b/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php @@ -5,7 +5,7 @@ use App\Models\BackupSchedule; use App\Models\BackupScheduleRun; use App\Models\BackupSet; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\RestoreRun; @@ -79,7 +79,7 @@ 'recorded_at' => now(), ]); - BulkOperationRun::factory()->create([ + OperationRun::factory()->create([ 'tenant_id' => $tenantA->id, 'user_id' => $user->id, 'status' => 'completed', @@ -130,7 +130,7 @@ expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0); expect(RestoreRun::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0); expect(AuditLog::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); - expect(BulkOperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); expect(BackupSchedule::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); diff --git a/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php b/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php index 105ab0c..c9946fd 100644 --- a/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php +++ b/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php @@ -78,11 +78,15 @@ expect($operationRun->started_at?->format('Y-m-d H:i:s'))->toBe($startedAt->format('Y-m-d H:i:s')); expect($operationRun->completed_at?->format('Y-m-d H:i:s'))->toBe($finishedAt->format('Y-m-d H:i:s')); - expect($operationRun->summary_counts)->toMatchArray([ + expect($operationRun->context)->toMatchArray([ 'backup_schedule_id' => (int) $schedule->id, 'backup_schedule_run_id' => (int) $scheduleRun->id, - 'policies_total' => 5, - 'policies_backed_up' => 18, - 'sync_failures' => 0, + ]); + + expect($operationRun->summary_counts)->toMatchArray([ + 'total' => 5, + 'processed' => 5, + 'succeeded' => 18, + 'items' => 5, ]); }); diff --git a/tests/Feature/Drift/DriftCompletedRunWithZeroFindingsTest.php b/tests/Feature/Drift/DriftCompletedRunWithZeroFindingsTest.php index f8832aa..df94b37 100644 --- a/tests/Feature/Drift/DriftCompletedRunWithZeroFindingsTest.php +++ b/tests/Feature/Drift/DriftCompletedRunWithZeroFindingsTest.php @@ -2,9 +2,8 @@ use App\Filament\Pages\DriftLanding; use App\Jobs\GenerateDriftFindingsJob; -use App\Models\BulkOperationRun; use App\Models\InventorySyncRun; -use App\Support\RunIdempotency; +use App\Models\OperationRun; use Filament\Facades\Filament; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; @@ -32,28 +31,26 @@ 'finished_at' => now()->subDay(), ]); - $idempotencyKey = RunIdempotency::buildKey( - tenantId: (int) $tenant->getKey(), - operationType: 'drift.generate', - targetId: $scopeKey, - context: [ + OperationRun::create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'drift.generate', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'run_identity_hash' => 'drift-zero-findings', + 'summary_counts' => [ + 'total' => 1, + 'processed' => 1, + 'succeeded' => 1, + 'failed' => 0, + 'created' => 0, + ], + 'context' => [ 'scope_key' => $scopeKey, 'baseline_run_id' => (int) $baseline->getKey(), 'current_run_id' => (int) $current->getKey(), ], - ); - - BulkOperationRun::factory()->for($tenant)->for($user)->create([ - 'resource' => 'drift', - 'action' => 'generate', - 'status' => 'completed', - 'idempotency_key' => $idempotencyKey, - 'item_ids' => [$scopeKey], - 'total_items' => 1, - 'processed_items' => 1, - 'succeeded' => 1, - 'failed' => 0, - 'skipped' => 0, ]); Livewire::test(DriftLanding::class) @@ -62,10 +59,5 @@ Queue::assertNothingPushed(); - expect(BulkOperationRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('idempotency_key', $idempotencyKey) - ->count())->toBe(1); - Queue::assertNotPushed(GenerateDriftFindingsJob::class); }); diff --git a/tests/Feature/Drift/DriftGenerationDispatchTest.php b/tests/Feature/Drift/DriftGenerationDispatchTest.php index 5e3f748..a79c8c0 100644 --- a/tests/Feature/Drift/DriftGenerationDispatchTest.php +++ b/tests/Feature/Drift/DriftGenerationDispatchTest.php @@ -2,12 +2,10 @@ use App\Filament\Pages\DriftLanding; use App\Jobs\GenerateDriftFindingsJob; -use App\Models\BulkOperationRun; use App\Models\InventorySyncRun; use App\Models\OperationRun; use App\Services\Graph\GraphClientInterface; use App\Support\OperationRunLinks; -use App\Support\RunIdempotency; use Filament\Facades\Filament; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; @@ -46,33 +44,6 @@ Livewire::test(DriftLanding::class); - $idempotencyKey = RunIdempotency::buildKey( - tenantId: (int) $tenant->getKey(), - operationType: 'drift.generate', - targetId: $scopeKey, - context: [ - 'scope_key' => $scopeKey, - 'baseline_run_id' => (int) $baseline->getKey(), - 'current_run_id' => (int) $current->getKey(), - ], - ); - - $bulkRun = BulkOperationRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('idempotency_key', $idempotencyKey) - ->latest('id') - ->first(); - - expect($bulkRun)->not->toBeNull(); - expect($bulkRun->resource)->toBe('drift'); - expect($bulkRun->action)->toBe('generate'); - expect($bulkRun->status)->toBe('pending'); - expect($bulkRun->item_ids)->toBe([ - 'scope_key' => $scopeKey, - 'baseline_run_id' => (int) $baseline->getKey(), - 'current_run_id' => (int) $current->getKey(), - ]); - $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->where('type', 'drift.generate') @@ -88,13 +59,12 @@ expect(collect($notifications)->last()['actions'][0]['url'] ?? null) ->toBe(OperationRunLinks::view($opRun, $tenant)); - Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $bulkRun, $opRun): bool { + Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $opRun): bool { return $job->tenantId === (int) $tenant->getKey() && $job->userId === (int) $user->getKey() && $job->baselineRunId === (int) $baseline->getKey() && $job->currentRunId === (int) $current->getKey() && $job->scopeKey === $scopeKey - && $job->bulkOperationRunId === (int) $bulkRun->getKey() && $job->operationRun instanceof OperationRun && (int) $job->operationRun->getKey() === (int) $opRun?->getKey(); }); @@ -126,22 +96,6 @@ Queue::assertPushed(GenerateDriftFindingsJob::class, 1); - $idempotencyKey = RunIdempotency::buildKey( - tenantId: (int) $tenant->getKey(), - operationType: 'drift.generate', - targetId: $scopeKey, - context: [ - 'scope_key' => $scopeKey, - 'baseline_run_id' => (int) $baseline->getKey(), - 'current_run_id' => (int) $current->getKey(), - ], - ); - - expect(BulkOperationRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('idempotency_key', $idempotencyKey) - ->count())->toBe(1); - expect(OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->where('type', 'drift.generate') @@ -166,7 +120,6 @@ Livewire::test(DriftLanding::class); Queue::assertNothingPushed(); - expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); }); @@ -194,6 +147,5 @@ Livewire::test(DriftLanding::class); Queue::assertNothingPushed(); - expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); }); diff --git a/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php b/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php index 4cdf501..b1a21f2 100644 --- a/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php +++ b/tests/Feature/Drift/GenerateDriftFindingsJobNotificationTest.php @@ -1,18 +1,20 @@ set('tenantpilot.bulk_operations.concurrency.per_target_scope_max', 1); + $scopeKey = hash('sha256', 'scope-job-notification-success'); $baseline = InventorySyncRun::factory()->for($tenant)->create([ @@ -27,20 +29,6 @@ 'finished_at' => now()->subDay(), ]); - $run = BulkOperationRun::factory()->create([ - 'tenant_id' => $tenant->getKey(), - 'user_id' => $user->getKey(), - 'resource' => 'drift', - 'action' => 'generate', - 'status' => 'pending', - 'total_items' => 1, - 'processed_items' => 0, - 'succeeded' => 0, - 'failed' => 0, - 'skipped' => 0, - 'failures' => [], - ]); - $opRun = OperationRun::create([ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), @@ -50,6 +38,9 @@ 'outcome' => 'pending', 'run_identity_hash' => 'drift-hash-1', 'context' => [ + 'target_scope' => [ + 'entra_tenant_id' => 'entra-1', + ], 'scope_key' => $scopeKey, 'baseline_run_id' => (int) $baseline->getKey(), 'current_run_id' => (int) $current->getKey(), @@ -66,18 +57,22 @@ baselineRunId: (int) $baseline->getKey(), currentRunId: (int) $current->getKey(), scopeKey: $scopeKey, - bulkOperationRunId: (int) $run->getKey(), operationRun: $opRun, ); - $job->handle(app(DriftFindingGenerator::class), app(BulkOperationService::class)); + $job->handle( + app(DriftFindingGenerator::class), + app(OperationRunService::class), + app(TargetScopeConcurrencyLimiter::class), + ); - expect($run->refresh()->status)->toBe('completed'); + $opRun->refresh(); + expect($opRun->status)->toBe('completed'); $this->assertDatabaseHas('notifications', [ 'notifiable_id' => $user->getKey(), 'notifiable_type' => $user->getMorphClass(), - 'type' => DatabaseNotification::class, + 'type' => OperationRunCompleted::class, ]); $notification = $user->notifications()->latest('id')->first(); @@ -89,6 +84,8 @@ test('drift generation job sends failure notification with view link', function () { [$user, $tenant] = createUserWithTenant(role: 'manager'); + config()->set('tenantpilot.bulk_operations.concurrency.per_target_scope_max', 1); + $scopeKey = hash('sha256', 'scope-job-notification-failure'); $baseline = InventorySyncRun::factory()->for($tenant)->create([ @@ -103,20 +100,6 @@ 'finished_at' => now()->subDay(), ]); - $run = BulkOperationRun::factory()->create([ - 'tenant_id' => $tenant->getKey(), - 'user_id' => $user->getKey(), - 'resource' => 'drift', - 'action' => 'generate', - 'status' => 'pending', - 'total_items' => 1, - 'processed_items' => 0, - 'succeeded' => 0, - 'failed' => 0, - 'skipped' => 0, - 'failures' => [], - ]); - $opRun = OperationRun::create([ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), @@ -126,6 +109,9 @@ 'outcome' => 'pending', 'run_identity_hash' => 'drift-hash-2', 'context' => [ + 'target_scope' => [ + 'entra_tenant_id' => 'entra-1', + ], 'scope_key' => $scopeKey, 'baseline_run_id' => (int) $baseline->getKey(), 'current_run_id' => (int) $current->getKey(), @@ -133,7 +119,7 @@ ]); $this->mock(DriftFindingGenerator::class, function (MockInterface $mock) { - $mock->shouldReceive('generate')->once()->andThrow(new RuntimeException('boom')); + $mock->shouldReceive('generate')->once()->andThrow(new \RuntimeException('boom')); }); $job = new GenerateDriftFindingsJob( @@ -142,31 +128,27 @@ baselineRunId: (int) $baseline->getKey(), currentRunId: (int) $current->getKey(), scopeKey: $scopeKey, - bulkOperationRunId: (int) $run->getKey(), operationRun: $opRun, ); try { - $job->handle(app(DriftFindingGenerator::class), app(BulkOperationService::class)); - } catch (RuntimeException) { + $job->handle( + app(DriftFindingGenerator::class), + app(OperationRunService::class), + app(TargetScopeConcurrencyLimiter::class), + ); + } catch (\RuntimeException) { // Expected. } - $run->refresh(); - - expect($run->status)->toBe('failed') - ->and($run->processed_items)->toBe(1) - ->and($run->failed)->toBe(1) - ->and($run->failures)->toBeArray() - ->and($run->failures)->toHaveCount(1) - ->and($run->failures[0]['item_id'] ?? null)->toBe($scopeKey) - ->and($run->failures[0]['reason_code'] ?? null)->toBe('unknown') - ->and($run->failures[0]['reason'] ?? null)->toBe('boom'); + $opRun->refresh(); + expect($opRun->status)->toBe('completed') + ->and($opRun->outcome)->toBe('failed'); $this->assertDatabaseHas('notifications', [ 'notifiable_id' => $user->getKey(), 'notifiable_type' => $user->getMorphClass(), - 'type' => DatabaseNotification::class, + 'type' => OperationRunCompleted::class, ]); $notification = $user->notifications()->latest('id')->first(); diff --git a/tests/Feature/ExecuteRestoreRunJobTest.php b/tests/Feature/ExecuteRestoreRunJobTest.php index 3f2bc73..c6dc634 100644 --- a/tests/Feature/ExecuteRestoreRunJobTest.php +++ b/tests/Feature/ExecuteRestoreRunJobTest.php @@ -6,7 +6,6 @@ use App\Models\Policy; use App\Models\RestoreRun; use App\Models\Tenant; -use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreService; use App\Support\RestoreRunStatus; @@ -62,7 +61,7 @@ }); $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor'); - $job->handle($restoreService, app(AuditLogger::class), app(BulkOperationService::class)); + $job->handle($restoreService, app(AuditLogger::class)); $restoreRun->refresh(); @@ -118,7 +117,7 @@ ]); $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor'); - $job->handle(app(RestoreService::class), app(AuditLogger::class), app(BulkOperationService::class)); + $job->handle(app(RestoreService::class), app(AuditLogger::class)); $restoreRun->refresh(); diff --git a/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php b/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php index c63f59e..3e4554b 100644 --- a/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php +++ b/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php @@ -3,13 +3,12 @@ use App\Jobs\AddPoliciesToBackupSetJob; use App\Livewire\BackupSetPolicyPickerTable; use App\Models\BackupSet; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; use App\Models\User; use App\Services\Intune\BackupService; -use App\Support\RunIdempotency; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; @@ -60,37 +59,25 @@ ->values() ->all(); - $key = RunIdempotency::buildKey( - tenantId: (int) $tenant->getKey(), - operationType: 'backup_set.add_policies', - targetId: (string) $backupSet->getKey(), - context: [ - 'policy_ids' => $policyIds, - 'include_assignments' => true, - 'include_scope_tags' => true, - 'include_foundations' => true, - ], - ); - - $run = BulkOperationRun::query() + $run = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('resource', 'backup_set') - ->where('action', 'add_policies') - ->where('idempotency_key', $key) + ->where('type', 'backup_set.add_policies') ->latest('id') ->first(); expect($run)->not->toBeNull(); - expect($run?->status)->toBe('pending'); - expect($run?->total_items)->toBe(count($policyIds)); - expect($run?->item_ids['backup_set_id'] ?? null)->toBe($backupSet->getKey()); - expect($run?->item_ids['policy_ids'] ?? null)->toBe($policyIds); - expect($run?->item_ids['options']['include_foundations'] ?? null)->toBeTrue(); + expect($run?->status)->toBe('queued'); + expect($run?->outcome)->toBe('pending'); + expect($run?->context['backup_set_id'] ?? null)->toBe($backupSet->getKey()); + expect($run?->context['policy_count'] ?? null)->toBe(count($policyIds)); + expect($run?->context['operation']['type'] ?? null)->toBe('backup_set.add_policies'); + expect($run?->context['selection']['kind'] ?? null)->toBe('ids'); + expect($run?->context['idempotency']['fingerprint'] ?? null)->not->toBeNull(); $notifications = session('filament.notifications', []); expect($notifications)->not->toBeEmpty(); - expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items queued'); + expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup set update queued'); }); test('policy picker table reuses an active run on double click (idempotency)', function () { @@ -120,18 +107,6 @@ ->values() ->all(); - $key = RunIdempotency::buildKey( - tenantId: (int) $tenant->getKey(), - operationType: 'backup_set.add_policies', - targetId: (string) $backupSet->getKey(), - context: [ - 'policy_ids' => $policyIds, - 'include_assignments' => true, - 'include_scope_tags' => true, - 'include_foundations' => true, - ], - ); - Livewire::actingAs($user) ->test(BackupSetPolicyPickerTable::class, [ 'backupSetId' => $backupSet->id, @@ -144,9 +119,9 @@ ]) ->callTableBulkAction('add_selected_to_backup_set', $policies); - expect(BulkOperationRun::query() + expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('idempotency_key', $key) + ->where('type', 'backup_set.add_policies') ->count())->toBe(1); Queue::assertPushed(AddPoliciesToBackupSetJob::class, 1); @@ -188,7 +163,10 @@ Queue::assertNothingPushed(); - expect(BulkOperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); + expect(OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'backup_set.add_policies') + ->exists())->toBeFalse(); }); test('policy picker table rejects cross-tenant starts (403) with no run records created', function () { @@ -235,7 +213,15 @@ Queue::assertNothingPushed(); - expect(BulkOperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse(); + expect(OperationRun::query() + ->where('tenant_id', $tenantA->id) + ->where('type', 'backup_set.add_policies') + ->exists())->toBeFalse(); + + expect(OperationRun::query() + ->where('tenant_id', $tenantB->id) + ->where('type', 'backup_set.add_policies') + ->exists())->toBeFalse(); }); test('policy picker table can filter by has versions', function () { diff --git a/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php b/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php index f7cd45f..34cbe48 100644 --- a/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php +++ b/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php @@ -2,6 +2,7 @@ use App\Filament\Resources\PolicyResource\Pages\ViewPolicy; use App\Jobs\CapturePolicySnapshotJob; +use App\Jobs\Operations\CapturePolicySnapshotWorkerJob; use App\Models\Policy; use App\Models\Tenant; use App\Models\User; @@ -9,7 +10,9 @@ use App\Services\Graph\ScopeTagResolver; use App\Services\Intune\PolicySnapshotService; use Filament\Facades\Filament; +use Illuminate\Contracts\Cache\Lock; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; use Mockery\MockInterface; @@ -64,6 +67,38 @@ $job = null; + $lock = new class implements Lock + { + public function get($callback = null): bool + { + return true; + } + + public function block($seconds, $callback = null): bool + { + return true; + } + + public function release(): bool + { + return true; + } + + public function owner(): string + { + return 'test'; + } + + public function forceRelease(): void + { + // no-op + } + }; + + Cache::partialMock() + ->shouldReceive('lock') + ->andReturn($lock); + Queue::assertPushed(CapturePolicySnapshotJob::class, function (CapturePolicySnapshotJob $queuedJob) use (&$job): bool { $job = $queuedJob; @@ -74,6 +109,18 @@ app()->call([$job, 'handle']); + $worker = null; + + Queue::assertPushed(CapturePolicySnapshotWorkerJob::class, function (CapturePolicySnapshotWorkerJob $queuedWorker) use (&$worker): bool { + $worker = $queuedWorker; + + return true; + }); + + expect($worker)->not->toBeNull(); + + app()->call([$worker, 'handle']); + $version = $policy->versions()->first(); expect($version)->not->toBeNull(); diff --git a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php index c95165c..14347cf 100644 --- a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php +++ b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php @@ -67,12 +67,11 @@ Bus::assertDispatchedTimes(BulkTenantSyncJob::class, 1); - $this->assertDatabaseHas('bulk_operation_runs', [ + $this->assertDatabaseHas('operation_runs', [ 'tenant_id' => $tenantA->id, 'user_id' => $user->id, - 'resource' => 'tenant', - 'action' => 'sync', - 'total_items' => 2, + 'type' => 'tenant.sync', + 'status' => 'queued', ]); }); diff --git a/tests/Feature/Guards/NoAdHocRetryInBulkWorkersTest.php b/tests/Feature/Guards/NoAdHocRetryInBulkWorkersTest.php new file mode 100644 index 0000000..30dab4a --- /dev/null +++ b/tests/Feature/Guards/NoAdHocRetryInBulkWorkersTest.php @@ -0,0 +1,32 @@ +filter(fn (\SplFileInfo $file): bool => $file->isFile() && $file->getExtension() === 'php') + ->map(fn (\SplFileInfo $file): string => $file->getPathname()) + ->values(); + + expect($paths)->not->toBeEmpty(); + + $violations = []; + + foreach ($paths as $path) { + $contents = file_get_contents($path); + + if ($contents === false) { + continue; + } + + if (Str::contains($contents, ['sleep(', 'usleep('])) { + $violations[] = $path; + } + } + + expect($violations)->toBe([]); +}); diff --git a/tests/Feature/Guards/NoLegacyBulkOperationsTest.php b/tests/Feature/Guards/NoLegacyBulkOperationsTest.php new file mode 100644 index 0000000..8a8b12c --- /dev/null +++ b/tests/Feature/Guards/NoLegacyBulkOperationsTest.php @@ -0,0 +1,101 @@ + $files */ + $files = collect($directories) + ->filter(fn (string $dir): bool => is_dir($dir)) + ->flatMap(function (string $dir): array { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS) + ); + + $paths = []; + + foreach ($iterator as $file) { + if (! $file->isFile()) { + continue; + } + + $path = $file->getPathname(); + + if (! str_ends_with($path, '.php')) { + continue; + } + + $paths[] = $path; + } + + return $paths; + }) + ->filter(function (string $path) use ($excludedPaths, $self): bool { + if ($self && realpath($path) === $self) { + return false; + } + + foreach ($excludedPaths as $excluded) { + if (str_starts_with($path, $excluded)) { + return false; + } + } + + return true; + }) + ->values(); + + $hits = []; + + foreach ($files as $path) { + $contents = file_get_contents($path); + + if (! is_string($contents) || $contents === '') { + continue; + } + + foreach ($forbiddenPatterns as $pattern) { + if (! preg_match($pattern, $contents)) { + continue; + } + + $lines = preg_split('/\R/', $contents) ?: []; + + foreach ($lines as $index => $line) { + if (preg_match($pattern, $line)) { + $relative = str_replace($root.'/', '', $path); + $hits[] = $relative.':'.($index + 1).' -> '.trim($line); + } + } + } + } + + expect($hits)->toBeEmpty('Legacy bulk references found:\n'.implode("\n", $hits)); +}); diff --git a/tests/Feature/Inventory/InventorySyncButtonTest.php b/tests/Feature/Inventory/InventorySyncButtonTest.php index c5d8385..ddeb0e8 100644 --- a/tests/Feature/Inventory/InventorySyncButtonTest.php +++ b/tests/Feature/Inventory/InventorySyncButtonTest.php @@ -3,8 +3,8 @@ use App\Filament\Pages\InventoryLanding; use App\Jobs\RunInventorySyncJob; use App\Livewire\BulkOperationProgress; -use App\Models\BulkOperationRun; use App\Models\InventorySyncRun; +use App\Models\OperationRun; use App\Models\Tenant; use App\Services\Inventory\InventorySyncService; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -35,15 +35,14 @@ expect($run->user_id)->toBe($user->id); expect($run->status)->toBe(InventorySyncRun::STATUS_PENDING); - $bulkRun = BulkOperationRun::query() + $opRun = OperationRun::query() ->where('tenant_id', $tenant->id) ->where('user_id', $user->id) - ->where('resource', 'inventory') - ->where('action', 'sync') + ->where('type', 'inventory.sync') ->latest('id') ->first(); - expect($bulkRun)->not->toBeNull(); + expect($opRun)->not->toBeNull(); }); it('dispatches inventory sync for selected policy types', function () { @@ -167,7 +166,7 @@ Queue::assertNothingPushed(); expect(InventorySyncRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse(); - expect(BulkOperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse(); + expect(OperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse(); }); it('blocks dispatch when a matching run is already pending or running', function () { @@ -202,7 +201,7 @@ Queue::assertNothingPushed(); expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->count())->toBe(1); - expect(BulkOperationRun::query()->where('tenant_id', $tenant->id)->count())->toBe(0); + expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory.sync')->count())->toBe(1); }); it('forbids unauthorized users from starting inventory sync', function () { diff --git a/tests/Feature/Inventory/RunInventorySyncJobTest.php b/tests/Feature/Inventory/RunInventorySyncJobTest.php index b0ed7da..772dd9e 100644 --- a/tests/Feature/Inventory/RunInventorySyncJobTest.php +++ b/tests/Feature/Inventory/RunInventorySyncJobTest.php @@ -2,7 +2,7 @@ use App\Jobs\RunInventorySyncJob; use App\Models\InventorySyncRun; -use App\Services\BulkOperationService; +use App\Services\OperationRunService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Intune\AuditLogger; @@ -25,37 +25,40 @@ $policyTypes = $computed['selection']['policy_types']; $run = $sync->createPendingRunForUser($tenant, $user, $computed['selection']); - $bulkRun = app(BulkOperationService::class)->createRun( + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( tenant: $tenant, - user: $user, - resource: 'inventory', - action: 'sync', - itemIds: $policyTypes, - totalItems: count($policyTypes), + type: 'inventory.sync', + inputs: $computed['selection'], + initiator: $user, ); $job = new RunInventorySyncJob( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), - bulkRunId: (int) $bulkRun->getKey(), inventorySyncRunId: (int) $run->getKey(), + operationRun: $opRun, ); - $job->handle(app(BulkOperationService::class), $sync, app(AuditLogger::class)); + $job->handle($sync, app(AuditLogger::class), $opService); $run->refresh(); - $bulkRun->refresh(); + $opRun->refresh(); expect($run->user_id)->toBe($user->id); expect($run->status)->toBe(InventorySyncRun::STATUS_SUCCESS); expect($run->started_at)->not->toBeNull(); expect($run->finished_at)->not->toBeNull(); - expect($bulkRun->status)->toBe('completed'); - expect($bulkRun->failed)->toBe(0); - expect($bulkRun->skipped)->toBe(0); - expect($bulkRun->processed_items)->toBeGreaterThan(0); - expect($bulkRun->processed_items)->toBe($bulkRun->succeeded); + expect($opRun->status)->toBe('completed'); + expect($opRun->outcome)->toBe('succeeded'); + + $counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : []; + expect((int) ($counts['total'] ?? 0))->toBe(count($policyTypes)); + expect((int) ($counts['processed'] ?? 0))->toBe(count($policyTypes)); + expect((int) ($counts['succeeded'] ?? 0))->toBe(count($policyTypes)); + expect((int) ($counts['failed'] ?? 0))->toBe(0); }); it('maps skipped inventory sync runs to bulk progress as skipped with reason', function () { @@ -70,13 +73,13 @@ $run->update(['selection_payload' => $computed['selection']]); - $bulkRun = app(BulkOperationService::class)->createRun( + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( tenant: $tenant, - user: $user, - resource: 'inventory', - action: 'sync', - itemIds: $policyTypes, - totalItems: count($policyTypes), + type: 'inventory.sync', + inputs: $computed['selection'], + initiator: $user, ); $mockSync = \Mockery::mock(InventorySyncService::class); @@ -98,24 +101,28 @@ $job = new RunInventorySyncJob( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), - bulkRunId: (int) $bulkRun->getKey(), inventorySyncRunId: (int) $run->getKey(), + operationRun: $opRun, ); - $job->handle(app(BulkOperationService::class), $mockSync, app(AuditLogger::class)); + $job->handle($mockSync, app(AuditLogger::class), $opService); $run->refresh(); - $bulkRun->refresh(); + $opRun->refresh(); expect($run->status)->toBe(InventorySyncRun::STATUS_SKIPPED); - expect($bulkRun->status)->toBe('completed') - ->and($bulkRun->processed_items)->toBe(count($policyTypes)) - ->and($bulkRun->skipped)->toBe(count($policyTypes)) - ->and($bulkRun->succeeded)->toBe(0) - ->and($bulkRun->failed)->toBe(0); + expect($opRun->status)->toBe('completed'); + expect($opRun->outcome)->toBe('failed'); - expect($bulkRun->failures)->toBeArray(); - expect($bulkRun->failures[0]['type'] ?? null)->toBe('skipped'); - expect($bulkRun->failures[0]['reason'] ?? null)->toBe('locked'); + $counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : []; + expect((int) ($counts['processed'] ?? 0))->toBe(count($policyTypes)); + expect((int) ($counts['skipped'] ?? 0))->toBe(count($policyTypes)); + expect((int) ($counts['succeeded'] ?? 0))->toBe(0); + expect((int) ($counts['failed'] ?? 0))->toBe(0); + + $failures = is_array($opRun->failure_summary) ? $opRun->failure_summary : []; + expect($failures)->toBeArray(); + expect($failures[0]['code'] ?? null)->toBe('inventory.skipped'); + expect($failures[0]['message'] ?? null)->toBe('locked'); }); diff --git a/tests/Feature/MonitoringOperationsTest.php b/tests/Feature/MonitoringOperationsTest.php index 4571262..3988cc2 100644 --- a/tests/Feature/MonitoringOperationsTest.php +++ b/tests/Feature/MonitoringOperationsTest.php @@ -13,7 +13,7 @@ $run = OperationRun::create([ 'tenant_id' => $tenant->id, - 'type' => 'test.run', + 'type' => 'policy.sync', 'status' => 'queued', 'outcome' => 'pending', 'initiator_name' => 'System', @@ -23,7 +23,7 @@ $this->actingAs($user) ->get(OperationRunResource::getUrl('index', tenant: $tenant)) ->assertSuccessful() - ->assertSee('test.run'); + ->assertSee('Policy sync'); }); it('renders monitoring pages DB-only (never calls Graph)', function () { @@ -33,7 +33,7 @@ $run = OperationRun::create([ 'tenant_id' => $tenant->id, - 'type' => 'test.run', + 'type' => 'policy.sync', 'status' => 'queued', 'outcome' => 'pending', 'initiator_name' => 'System', @@ -72,7 +72,7 @@ OperationRun::create([ 'tenant_id' => $tenantA->id, - 'type' => 'tenantA.run', + 'type' => 'policy.sync', 'status' => 'queued', 'outcome' => 'pending', 'initiator_name' => 'System', @@ -81,7 +81,7 @@ OperationRun::create([ 'tenant_id' => $tenantB->id, - 'type' => 'tenantB.run', + 'type' => 'inventory.sync', 'status' => 'queued', 'outcome' => 'pending', 'initiator_name' => 'System', @@ -93,8 +93,8 @@ // The cleanest way is to just GET the page URL, which runs middleware. $this->get(OperationRunResource::getUrl('index', tenant: $tenantA)) - ->assertSee('tenantA.run') - ->assertDontSee('tenantB.run'); + ->assertSee('Policy sync') + ->assertDontSee('Inventory sync'); }); it('allows readonly users to view operations list and detail', function () { @@ -104,7 +104,7 @@ $run = OperationRun::create([ 'tenant_id' => $tenant->id, - 'type' => 'test.run', + 'type' => 'policy.sync', 'status' => 'queued', 'outcome' => 'pending', 'initiator_name' => 'System', @@ -114,12 +114,12 @@ $this->actingAs($user) ->get(OperationRunResource::getUrl('index', tenant: $tenant)) ->assertSuccessful() - ->assertSee('test.run'); + ->assertSee('Policy sync'); $this->actingAs($user) ->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)) ->assertSuccessful() - ->assertSee('test.run'); + ->assertSee('Policy sync'); }); it('denies access to unauthorized users', function () { diff --git a/tests/Feature/OpsUx/AdapterRunReconcilerTest.php b/tests/Feature/OpsUx/AdapterRunReconcilerTest.php new file mode 100644 index 0000000..42794b2 --- /dev/null +++ b/tests/Feature/OpsUx/AdapterRunReconcilerTest.php @@ -0,0 +1,184 @@ +create([ + 'tenant_id' => $tenant->getKey(), + ]); + + $restoreRun = RestoreRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'backup_set_id' => $backupSet->getKey(), + 'status' => 'completed', + 'is_dry_run' => false, + 'started_at' => CarbonImmutable::now()->subMinutes(20), + 'completed_at' => CarbonImmutable::now()->subMinutes(10), + 'metadata' => [ + 'total' => 10, + 'succeeded' => 8, + 'failed' => 1, + 'skipped' => 1, + ], + // Intentionally malformed outcomes to ensure reconciler never explodes. + 'results' => [ + 'items' => [ + '123' => [ + 'assignment_outcomes' => ['not-an-array'], + ], + ], + ], + ]); + + $opRun = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'restore.execute', + 'status' => 'queued', + 'outcome' => 'pending', + 'started_at' => null, + 'completed_at' => null, + 'created_at' => CarbonImmutable::now()->subMinutes(120), + 'context' => [ + 'restore_run_id' => $restoreRun->getKey(), + ], + 'summary_counts' => [], + ]); + + $result = app(AdapterRunReconciler::class)->reconcile([ + 'type' => 'restore.execute', + 'tenant_id' => (int) $tenant->getKey(), + 'older_than_minutes' => 10, + 'limit' => 10, + 'dry_run' => false, + ]); + + expect($result['reconciled'] ?? null)->toBe(1); + + $opRun->refresh(); + + expect($opRun->status)->toBe('completed'); + expect($opRun->outcome)->toBe('succeeded'); + + expect($opRun->summary_counts['total'] ?? null)->toBe(10); + expect($opRun->summary_counts['succeeded'] ?? null)->toBe(8); + expect($opRun->summary_counts['failed'] ?? null)->toBe(1); + expect($opRun->summary_counts['skipped'] ?? null)->toBe(1); + + $context = is_array($opRun->context) ? $opRun->context : []; + expect($context['reconciliation']['reason'] ?? null)->toBe('adapter_out_of_sync'); + expect($context['reconciliation']['reconciled_at'] ?? null)->toBeString(); + + expect($opRun->started_at)->not->toBeNull(); + expect($opRun->completed_at)->not->toBeNull(); +})->group('ops-ux'); + +it('is idempotent (second run performs no work)', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->getKey(), + ]); + + $restoreRun = RestoreRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'backup_set_id' => $backupSet->getKey(), + 'status' => 'completed', + 'is_dry_run' => false, + 'metadata' => [ + 'total' => 1, + 'succeeded' => 1, + ], + ]); + + OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'restore.execute', + 'status' => 'queued', + 'outcome' => 'pending', + 'created_at' => CarbonImmutable::now()->subMinutes(120), + 'context' => [ + 'restore_run_id' => $restoreRun->getKey(), + ], + ]); + + $reconciler = app(AdapterRunReconciler::class); + + $first = $reconciler->reconcile([ + 'tenant_id' => (int) $tenant->getKey(), + 'older_than_minutes' => 10, + 'limit' => 10, + 'dry_run' => false, + ]); + + expect($first['reconciled'] ?? null)->toBe(1); + + $second = $reconciler->reconcile([ + 'tenant_id' => (int) $tenant->getKey(), + 'older_than_minutes' => 10, + 'limit' => 10, + 'dry_run' => false, + ]); + + expect($second['candidates'] ?? null)->toBe(0); + expect($second['reconciled'] ?? null)->toBe(0); +})->group('ops-ux'); + +it('does not persist non-whitelisted summary_counts keys during reconciliation', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->getKey(), + ]); + + $restoreRun = RestoreRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'backup_set_id' => $backupSet->getKey(), + 'status' => 'completed', + 'is_dry_run' => false, + 'metadata' => [ + 'total' => 2, + 'succeeded' => 2, + 'secrets' => 999, + 'assignments_success' => 123, + ], + ]); + + $opRun = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'restore.execute', + 'status' => 'queued', + 'outcome' => 'pending', + 'created_at' => CarbonImmutable::now()->subMinutes(120), + 'context' => [ + 'restore_run_id' => $restoreRun->getKey(), + ], + ]); + + app(AdapterRunReconciler::class)->reconcile([ + 'tenant_id' => (int) $tenant->getKey(), + 'older_than_minutes' => 10, + 'limit' => 10, + 'dry_run' => false, + ]); + + $opRun->refresh(); + + expect($opRun->summary_counts['total'] ?? null)->toBe(2); + expect($opRun->summary_counts['succeeded'] ?? null)->toBe(2); + expect($opRun->summary_counts)->not->toHaveKey('secrets'); + expect($opRun->summary_counts)->not->toHaveKey('assignments_success'); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/BackupSetDeleteBulkJobTest.php b/tests/Feature/OpsUx/BackupSetDeleteBulkJobTest.php new file mode 100644 index 0000000..d6c5cd2 --- /dev/null +++ b/tests/Feature/OpsUx/BackupSetDeleteBulkJobTest.php @@ -0,0 +1,142 @@ +create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'backup_set.delete', + 'status' => 'queued', + 'summary_counts' => [], + 'context' => [ + 'target_scope' => [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + ], + ]); + + Bus::fake(); + + $job = new BulkBackupSetDeleteJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + backupSetIds: [3, 2, 1], + operationRun: $run, + context: [], + ); + + $job->handle(app(OperationRunService::class)); + + $run = $run->fresh(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('running'); + expect($run?->summary_counts['total'] ?? null)->toBe(3); + + Bus::assertDispatched(BackupSetDeleteWorkerJob::class, 3); +})->group('ops-ux'); + +it('archives only active backup sets and updates summary counts', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $active = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'deleted_at' => null, + ]); + + $alreadyArchived = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'deleted_at' => null, + ]); + $alreadyArchived->delete(); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'backup_set.delete', + 'status' => 'running', + 'summary_counts' => [ + 'total' => 2, + 'processed' => 0, + 'failed' => 0, + ], + 'context' => [ + 'target_scope' => [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + ], + ]); + + $lock = new class implements Lock + { + public function get($callback = null): bool + { + return true; + } + + public function block($seconds, $callback = null): bool + { + return true; + } + + public function release(): bool + { + return true; + } + + public function owner(): string + { + return 'test'; + } + + public function forceRelease(): void + { + // no-op + } + }; + + Cache::partialMock() + ->shouldReceive('lock') + ->andReturn($lock); + + (new BackupSetDeleteWorkerJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + backupSetId: (int) $active->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class)); + + (new BackupSetDeleteWorkerJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + backupSetId: (int) $alreadyArchived->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class)); + + $run = $run->fresh(); + + expect($active->fresh()?->trashed())->toBeTrue(); + expect($alreadyArchived->fresh()?->trashed())->toBeTrue(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('completed'); + expect($run?->outcome)->toBe('succeeded'); + + expect($run?->summary_counts['processed'] ?? null)->toBe(2); + expect($run?->summary_counts['succeeded'] ?? null)->toBe(1); + expect($run?->summary_counts['deleted'] ?? null)->toBe(1); + expect($run?->summary_counts['skipped'] ?? null)->toBe(1); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/BulkEnqueueIdempotencyTest.php b/tests/Feature/OpsUx/BulkEnqueueIdempotencyTest.php new file mode 100644 index 0000000..d96f51c --- /dev/null +++ b/tests/Feature/OpsUx/BulkEnqueueIdempotencyTest.php @@ -0,0 +1,74 @@ +create(['tenant_id' => $entraTenantId]); + $user = User::factory()->create(); + + $selectionIdentity = app(BulkSelectionIdentity::class)->fromIds(['a', 'b', 'c']); + + $service = app(OperationRunService::class); + + $dispatchCount = 0; + + $runA = $service->enqueueBulkOperation( + tenant: $tenant, + type: 'policy.delete', + targetScope: ['entra_tenant_id' => $entraTenantId], + selectionIdentity: $selectionIdentity, + dispatcher: function () use (&$dispatchCount): void { + $dispatchCount++; + }, + initiator: $user, + ); + + $runB = $service->enqueueBulkOperation( + tenant: $tenant, + type: 'policy.delete', + targetScope: ['entra_tenant_id' => $entraTenantId], + selectionIdentity: $selectionIdentity, + dispatcher: function () use (&$dispatchCount): void { + $dispatchCount++; + }, + initiator: $user, + ); + + expect($runA->getKey())->toBe($runB->getKey()); + expect($dispatchCount)->toBe(1); + + expect(OperationRun::query()->where('tenant_id', $tenant->id)->count())->toBe(1); +})->group('ops-ux'); + +it('passes the OperationRun to the dispatcher when accepted', function (): void { + $entraTenantId = '00000000-0000-0000-0000-000000000001'; + $tenant = Tenant::factory()->create(['tenant_id' => $entraTenantId]); + $user = User::factory()->create(); + + $selectionIdentity = app(BulkSelectionIdentity::class)->fromIds(['a']); + + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + $receivedId = null; + + $run = $service->enqueueBulkOperation( + tenant: $tenant, + type: 'policy.delete', + targetScope: ['entra_tenant_id' => $entraTenantId], + selectionIdentity: $selectionIdentity, + dispatcher: function (OperationRun $operationRun) use (&$receivedId): void { + $receivedId = $operationRun->getKey(); + }, + initiator: $user, + ); + + expect($receivedId)->toBe($run->getKey()); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/BulkTenantIsolationTest.php b/tests/Feature/OpsUx/BulkTenantIsolationTest.php new file mode 100644 index 0000000..eca32bf --- /dev/null +++ b/tests/Feature/OpsUx/BulkTenantIsolationTest.php @@ -0,0 +1,31 @@ +create([ + 'tenant_id' => 'tenant-a', + 'external_id' => 'tenant-a', + ]); + + /** @var BulkSelectionIdentity $selection */ + $selection = app(BulkSelectionIdentity::class); + + $selectionIdentity = $selection->fromIds([1, 2, 3]); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $runs->enqueueBulkOperation( + tenant: $tenant, + type: 'policy.bulk_delete', + targetScope: ['entra_tenant_id' => 'tenant-b'], + selectionIdentity: $selectionIdentity, + dispatcher: fn (): null => null, + initiator: null, + extraContext: [], + emitQueuedNotification: false, + ); +})->throws(InvalidArgumentException::class); diff --git a/tests/Feature/OpsUx/FailureSanitizationTest.php b/tests/Feature/OpsUx/FailureSanitizationTest.php new file mode 100644 index 0000000..b3e3147 --- /dev/null +++ b/tests/Feature/OpsUx/FailureSanitizationTest.php @@ -0,0 +1,62 @@ +create(); + $user = User::factory()->create(); + $tenant->users()->attach($user); + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $run = $runs->ensureRun( + tenant: $tenant, + type: 'test.sanitize', + inputs: [], + initiator: $user, + ); + + $rawBearer = 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.'.str_repeat('A', 90); + + $runs->updateRun( + $run, + status: 'completed', + outcome: 'failed', + failures: [[ + 'code' => 'graph_forbidden', + 'message' => "Authorization: {$rawBearer} client_secret=supersecret user=test.user@example.com", + ]], + ); + + $run->refresh(); + + $failureSummaryJson = json_encode($run->failure_summary, JSON_THROW_ON_ERROR); + + expect($failureSummaryJson)->not->toContain('client_secret=supersecret'); + expect($failureSummaryJson)->not->toContain($rawBearer); + expect($failureSummaryJson)->not->toContain('test.user@example.com'); + + expect($run->failure_summary[0]['reason_code'] ?? null)->toBe('permission_denied'); + + $notification = DatabaseNotification::query() + ->where('notifiable_id', $user->getKey()) + ->latest('id') + ->first(); + + expect($notification)->not->toBeNull(); + + $notificationJson = json_encode($notification?->data, JSON_THROW_ON_ERROR); + + expect($notificationJson)->not->toContain('client_secret=supersecret'); + expect($notificationJson)->not->toContain($rawBearer); + expect($notificationJson)->not->toContain('test.user@example.com'); + + $this->actingAs($user) + ->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)) + ->assertSuccessful(); +}); diff --git a/tests/Feature/OpsUx/NotificationViewRunLinkTest.php b/tests/Feature/OpsUx/NotificationViewRunLinkTest.php index bed43c2..20ae505 100644 --- a/tests/Feature/OpsUx/NotificationViewRunLinkTest.php +++ b/tests/Feature/OpsUx/NotificationViewRunLinkTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Models\OperationRun; +use App\Notifications\RunStatusChangedNotification; use App\Services\OperationRunService; use App\Support\OperationRunLinks; use Filament\Facades\Filament; @@ -40,3 +41,31 @@ expect($notification->data['actions'][0]['url'] ?? null) ->toBe(OperationRunLinks::view($run, $tenant)); })->group('ops-ux'); + +it('does not link to legacy bulk run resources in status-change notifications', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'policy.delete', + 'status' => 'queued', + 'outcome' => 'pending', + 'context' => ['operation' => ['type' => 'policy.delete']], + ]); + + $user->notify(new RunStatusChangedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'run_type' => 'bulk_operation', + 'run_id' => (int) $run->getKey(), + 'status' => 'completed', + ])); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + + $url = $notification->data['actions'][0]['url'] ?? null; + expect($url)->toBe(OperationRunLinks::view($run, $tenant)); + expect((string) $url)->not->toContain('bulk-operation-runs'); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/OperationRunSummaryCountsIncrementTest.php b/tests/Feature/OpsUx/OperationRunSummaryCountsIncrementTest.php new file mode 100644 index 0000000..586ada2 --- /dev/null +++ b/tests/Feature/OpsUx/OperationRunSummaryCountsIncrementTest.php @@ -0,0 +1,45 @@ +actingAs($user); + + Filament::setTenant($tenant, true); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'policy.delete', + 'status' => 'queued', + 'outcome' => 'pending', + 'context' => ['operation' => ['type' => 'policy.delete']], + 'summary_counts' => [ + 'total' => 10, + 'processed' => 1, + 'secrets' => 999, + ], + ]); + + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + $service->incrementSummaryCounts($run, [ + 'processed' => 2, + 'failed' => 1, + 'secrets' => 123, + ]); + + $run->refresh(); + + expect($run->summary_counts['total'] ?? null)->toBe(10); + expect($run->summary_counts['processed'] ?? null)->toBe(3); + expect($run->summary_counts['failed'] ?? null)->toBe(1); + expect($run->summary_counts)->not->toHaveKey('secrets'); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/PolicyVersionForceDeleteBulkJobTest.php b/tests/Feature/OpsUx/PolicyVersionForceDeleteBulkJobTest.php new file mode 100644 index 0000000..aae5bc2 --- /dev/null +++ b/tests/Feature/OpsUx/PolicyVersionForceDeleteBulkJobTest.php @@ -0,0 +1,150 @@ +create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'policy_version.force_delete', + 'status' => 'queued', + 'summary_counts' => [], + 'context' => [ + 'target_scope' => [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + ], + ]); + + Bus::fake(); + + $job = new BulkPolicyVersionForceDeleteJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyVersionIds: [3, 2, 1], + operationRun: $run, + context: [], + ); + + $job->handle(app(OperationRunService::class)); + + $run = $run->fresh(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('running'); + expect($run?->summary_counts['total'] ?? null)->toBe(3); + + Bus::assertDispatched(PolicyVersionForceDeleteWorkerJob::class, 3); +})->group('ops-ux'); + +it('force deletes only archived versions and updates summary counts', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + ]); + + $archived = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'deleted_at' => now(), + ]); + + $active = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 2, + 'deleted_at' => null, + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'policy_version.force_delete', + 'status' => 'running', + 'summary_counts' => [ + 'total' => 2, + 'processed' => 0, + 'failed' => 0, + ], + 'context' => [ + 'target_scope' => [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + ], + ]); + + $lock = new class implements Lock + { + public function get($callback = null): bool + { + return true; + } + + public function block($seconds, $callback = null): bool + { + return true; + } + + public function release(): bool + { + return true; + } + + public function owner(): string + { + return 'test'; + } + + public function forceRelease(): void + { + // no-op + } + }; + + Cache::partialMock() + ->shouldReceive('lock') + ->andReturn($lock); + + (new PolicyVersionForceDeleteWorkerJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyVersionId: (int) $archived->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class)); + + (new PolicyVersionForceDeleteWorkerJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyVersionId: (int) $active->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class)); + + $run = $run->fresh(); + + expect(PolicyVersion::withTrashed()->whereKey($archived->getKey())->exists())->toBeFalse(); + expect(PolicyVersion::withTrashed()->whereKey($active->getKey())->exists())->toBeTrue(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('completed'); + expect($run?->outcome)->toBe('succeeded'); + + expect($run?->summary_counts['processed'] ?? null)->toBe(2); + expect($run?->summary_counts['succeeded'] ?? null)->toBe(1); + expect($run?->summary_counts['deleted'] ?? null)->toBe(1); + expect($run?->summary_counts['skipped'] ?? null)->toBe(1); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/PolicyVersionPruneBulkJobTest.php b/tests/Feature/OpsUx/PolicyVersionPruneBulkJobTest.php new file mode 100644 index 0000000..108d949 --- /dev/null +++ b/tests/Feature/OpsUx/PolicyVersionPruneBulkJobTest.php @@ -0,0 +1,155 @@ +create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'policy_version.prune', + 'status' => 'queued', + 'summary_counts' => [], + 'context' => [ + 'target_scope' => [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + ], + ]); + + Bus::fake(); + + $job = new BulkPolicyVersionPruneJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyVersionIds: [3, 2, 1], + retentionDays: 90, + operationRun: $run, + context: [], + ); + + $job->handle(app(OperationRunService::class)); + + $run = $run->fresh(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('running'); + expect($run?->summary_counts['total'] ?? null)->toBe(3); + + Bus::assertDispatched(PolicyVersionPruneWorkerJob::class, 3); +})->group('ops-ux'); + +it('prunes only eligible versions and updates summary counts', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + ]); + + $eligible = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(120), + 'deleted_at' => null, + ]); + + $notEligibleCurrent = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 2, + 'captured_at' => now()->subDays(120), + 'deleted_at' => null, + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'policy_version.prune', + 'status' => 'running', + 'summary_counts' => [ + 'total' => 2, + 'processed' => 0, + 'failed' => 0, + ], + 'context' => [ + 'target_scope' => [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + ], + ]); + + $lock = new class implements Lock + { + public function get($callback = null): bool + { + return true; + } + + public function block($seconds, $callback = null): bool + { + return true; + } + + public function release(): bool + { + return true; + } + + public function owner(): string + { + return 'test'; + } + + public function forceRelease(): void + { + // no-op + } + }; + + Cache::partialMock() + ->shouldReceive('lock') + ->andReturn($lock); + + (new PolicyVersionPruneWorkerJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyVersionId: (int) $eligible->getKey(), + retentionDays: 90, + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class)); + + (new PolicyVersionPruneWorkerJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyVersionId: (int) $notEligibleCurrent->getKey(), + retentionDays: 90, + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class)); + + $run = $run->fresh(); + + expect($eligible->fresh()?->trashed())->toBeTrue(); + expect($notEligibleCurrent->fresh()?->trashed())->toBeFalse(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('completed'); + expect($run?->outcome)->toBe('succeeded'); + + expect($run?->summary_counts['processed'] ?? null)->toBe(2); + expect($run?->summary_counts['succeeded'] ?? null)->toBe(1); + expect($run?->summary_counts['deleted'] ?? null)->toBe(1); + expect($run?->summary_counts['skipped'] ?? null)->toBe(1); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/RestoreExecuteOperationRunSyncTest.php b/tests/Feature/OpsUx/RestoreExecuteOperationRunSyncTest.php new file mode 100644 index 0000000..795a1ca --- /dev/null +++ b/tests/Feature/OpsUx/RestoreExecuteOperationRunSyncTest.php @@ -0,0 +1,57 @@ +create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->getKey(), + ]); + + $restoreRun = RestoreRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'backup_set_id' => $backupSet->getKey(), + 'status' => 'completed', + 'is_dry_run' => false, + 'metadata' => [ + 'total' => 10, + 'succeeded' => 8, + 'failed' => 1, + 'skipped' => 1, + ], + // Intentionally malformed outcomes to ensure sync never explodes. + 'results' => [ + 'items' => [ + '123' => [ + 'assignment_outcomes' => ['not-an-array'], + ], + ], + ], + ]); + + app(SyncRestoreRunToOperationRun::class)->handle($restoreRun); + + $opRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'restore.execute') + ->where('context->restore_run_id', $restoreRun->getKey()) + ->first(); + + expect($opRun)->not->toBeNull(); + expect($opRun?->status)->toBe('completed'); + expect($opRun?->outcome)->toBe('succeeded'); + + expect($opRun?->summary_counts['total'] ?? null)->toBe(10); + expect($opRun?->summary_counts['succeeded'] ?? null)->toBe(8); + expect($opRun?->summary_counts['failed'] ?? null)->toBe(1); + expect($opRun?->summary_counts['skipped'] ?? null)->toBe(1); + + expect($opRun?->completed_at)->not->toBeNull(); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php b/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php index b977819..06ea62e 100644 --- a/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php +++ b/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php @@ -5,7 +5,6 @@ use App\Jobs\ExecuteRestoreRunJob; use App\Models\OperationRun; use App\Models\RestoreRun; -use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreService; @@ -35,10 +34,6 @@ expect($operationRun)->not->toBeNull(); expect($operationRun?->status)->toBe('queued'); - $this->mock(BulkOperationService::class, function ($mock): void { - $mock->shouldReceive('sanitizeFailureReason')->andReturnUsing(fn (string $message): string => $message); - }); - // Simulate downstream code updating RestoreRun status via query builder (no model events). $this->mock(RestoreService::class, function ($mock) use ($restoreRun): void { $mock->shouldReceive('executeForRun') @@ -57,7 +52,6 @@ $job->handle( app(RestoreService::class), app(AuditLogger::class), - app(BulkOperationService::class), ); $operationRun = $operationRun?->fresh(); diff --git a/tests/Feature/OpsUx/RestoreRunDeleteBulkJobTest.php b/tests/Feature/OpsUx/RestoreRunDeleteBulkJobTest.php new file mode 100644 index 0000000..d599ee7 --- /dev/null +++ b/tests/Feature/OpsUx/RestoreRunDeleteBulkJobTest.php @@ -0,0 +1,158 @@ +create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'restore_run.delete', + 'status' => 'queued', + 'summary_counts' => [], + 'context' => [ + 'target_scope' => [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + ], + ]); + + Bus::fake(); + + $job = new BulkRestoreRunDeleteJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + restoreRunIds: [3, 2, 1], + operationRun: $run, + context: [], + ); + + $job->handle(app(OperationRunService::class)); + + $run = $run->fresh(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('running'); + expect($run?->summary_counts['total'] ?? null)->toBe(3); + + Bus::assertDispatched(RestoreRunDeleteWorkerJob::class, 3); +})->group('ops-ux'); + +it('archives only deletable restore runs and updates summary counts', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $deletable = RestoreRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + 'deleted_at' => null, + ]); + + $alreadyArchived = RestoreRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + 'deleted_at' => now(), + ]); + + $notDeletable = RestoreRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'running', + 'deleted_at' => null, + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'restore_run.delete', + 'status' => 'running', + 'summary_counts' => [ + 'total' => 3, + 'processed' => 0, + 'failed' => 0, + ], + 'context' => [ + 'target_scope' => [ + 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), + ], + ], + ]); + + $lock = new class implements Lock + { + public function get($callback = null): bool + { + return true; + } + + public function block($seconds, $callback = null): bool + { + return true; + } + + public function release(): bool + { + return true; + } + + public function owner(): string + { + return 'test'; + } + + public function forceRelease(): void + { + // no-op + } + }; + + Cache::partialMock() + ->shouldReceive('lock') + ->andReturn($lock); + + (new RestoreRunDeleteWorkerJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + restoreRunId: (int) $deletable->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class)); + + (new RestoreRunDeleteWorkerJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + restoreRunId: (int) $alreadyArchived->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class)); + + (new RestoreRunDeleteWorkerJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + restoreRunId: (int) $notDeletable->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class)); + + $run = $run->fresh(); + + expect(RestoreRun::withTrashed()->whereKey($deletable->getKey())->first()?->trashed())->toBeTrue(); + expect(RestoreRun::withTrashed()->whereKey($alreadyArchived->getKey())->first()?->trashed())->toBeTrue(); + expect(RestoreRun::withTrashed()->whereKey($notDeletable->getKey())->first()?->trashed())->toBeFalse(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('completed'); + expect($run?->outcome)->toBe('succeeded'); + + expect($run?->summary_counts['processed'] ?? null)->toBe(3); + expect($run?->summary_counts['succeeded'] ?? null)->toBe(1); + expect($run?->summary_counts['deleted'] ?? null)->toBe(1); + expect($run?->summary_counts['skipped'] ?? null)->toBe(2); + expect($run?->summary_counts['failed'] ?? null)->toBe(0); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php b/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php index de070b3..555b1d1 100644 --- a/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php +++ b/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php @@ -49,3 +49,72 @@ expect($body)->not->toContain('secrets'); expect($body)->not->toContain('failed:'); })->group('ops-ux'); + +it('sanitizes summary_counts values and drops non-whitelisted keys', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'policy.delete', + 'status' => 'queued', + 'outcome' => 'pending', + 'context' => ['operation' => ['type' => 'policy.delete']], + 'summary_counts' => [], + ]); + + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + $service->updateRun( + $run, + status: 'running', + outcome: null, + summaryCounts: [ + 'processed' => '2', + 'failed' => 1.2, + 'secrets' => 999, + '' => 5, + 'total' => 'not-a-number', + ], + ); + + $run->refresh(); + + expect($run->summary_counts['processed'] ?? null)->toBe(2); + expect($run->summary_counts['failed'] ?? null)->toBe(1); + expect($run->summary_counts)->not->toHaveKey('secrets'); + expect($run->summary_counts)->not->toHaveKey(''); + expect($run->summary_counts)->not->toHaveKey('total'); +})->group('ops-ux'); + +it('ignores non-whitelisted keys when incrementing summary_counts', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'policy.delete', + 'status' => 'queued', + 'outcome' => 'pending', + 'context' => ['operation' => ['type' => 'policy.delete']], + 'summary_counts' => ['processed' => 1, 'secrets' => 5], + ]); + + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + $service->incrementSummaryCounts($run, [ + 'processed' => 1, + 'secrets' => 10, + ]); + + $run->refresh(); + + expect($run->summary_counts['processed'] ?? null)->toBe(2); + expect($run->summary_counts)->not->toHaveKey('secrets'); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/TargetScopeConcurrencyLimiterTest.php b/tests/Feature/OpsUx/TargetScopeConcurrencyLimiterTest.php new file mode 100644 index 0000000..b491a2a --- /dev/null +++ b/tests/Feature/OpsUx/TargetScopeConcurrencyLimiterTest.php @@ -0,0 +1,25 @@ +set('tenantpilot.bulk_operations.concurrency.per_target_scope_max', 1); + + $tenantId = 123; + + $limiter = app(TargetScopeConcurrencyLimiter::class); + + $lockA = $limiter->acquireSlot($tenantId, ['entra_tenant_id' => '00000000-0000-0000-0000-000000000001']); + expect($lockA)->not->toBeNull(); + + $lockB = $limiter->acquireSlot($tenantId, ['entra_tenant_id' => '00000000-0000-0000-0000-000000000001']); + expect($lockB)->toBeNull(); + + $lockOtherScope = $limiter->acquireSlot($tenantId, ['entra_tenant_id' => '00000000-0000-0000-0000-000000000002']); + expect($lockOtherScope)->not->toBeNull(); + + $lockA?->release(); + $lockOtherScope?->release(); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/TenantSyncBulkJobTest.php b/tests/Feature/OpsUx/TenantSyncBulkJobTest.php new file mode 100644 index 0000000..243ede0 --- /dev/null +++ b/tests/Feature/OpsUx/TenantSyncBulkJobTest.php @@ -0,0 +1,165 @@ +create([ + 'tenant_id' => $tenantContext->id, + 'user_id' => $user->id, + 'type' => 'tenant.sync', + 'status' => 'queued', + 'summary_counts' => [], + 'context' => [ + 'target_scope' => [ + 'entra_tenant_id' => (string) ($tenantContext->tenant_id ?? $tenantContext->external_id), + ], + ], + ]); + + Bus::fake(); + + $job = new BulkTenantSyncJob( + tenantId: (int) $tenantContext->getKey(), + userId: (int) $user->getKey(), + tenantIds: [3, 2, 1], + operationRun: $run, + context: [], + ); + + $job->handle(app(OperationRunService::class)); + + $run = $run->fresh(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('running'); + expect($run?->summary_counts['total'] ?? null)->toBe(3); + + Bus::assertDispatched(TenantSyncWorkerJob::class, 3); +})->group('ops-ux'); + +it('syncs eligible tenants and updates summary counts', function (): void { + [$user, $tenantContext] = createUserWithTenant(role: 'owner'); + + $eligible = Tenant::factory()->create([ + 'status' => 'active', + 'deleted_at' => null, + ]); + + $inactive = Tenant::factory()->create([ + 'status' => 'inactive', + 'deleted_at' => null, + ]); + + $unauthorized = Tenant::factory()->create([ + 'status' => 'active', + 'deleted_at' => null, + ]); + + $user->tenants()->syncWithoutDetaching([ + $eligible->getKey() => ['role' => 'owner'], + $inactive->getKey() => ['role' => 'owner'], + ]); + + mock(PolicySyncService::class) + ->shouldReceive('syncPolicies') + ->andReturn([]); + + $lock = new class implements Lock + { + public function get($callback = null): bool + { + return true; + } + + public function block($seconds, $callback = null): bool + { + return true; + } + + public function release(): bool + { + return true; + } + + public function owner(): string + { + return 'test'; + } + + public function forceRelease(): void + { + // no-op + } + }; + + Cache::partialMock() + ->shouldReceive('lock') + ->andReturn($lock); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenantContext->id, + 'user_id' => $user->id, + 'type' => 'tenant.sync', + 'status' => 'running', + 'summary_counts' => [ + 'total' => 4, + 'processed' => 0, + 'failed' => 0, + ], + 'context' => [ + 'target_scope' => [ + 'entra_tenant_id' => (string) ($tenantContext->tenant_id ?? $tenantContext->external_id), + ], + ], + ]); + + (new TenantSyncWorkerJob( + tenantId: (int) $eligible->getKey(), + userId: (int) $user->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class), app(PolicySyncService::class)); + + (new TenantSyncWorkerJob( + tenantId: (int) $inactive->getKey(), + userId: (int) $user->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class), app(PolicySyncService::class)); + + (new TenantSyncWorkerJob( + tenantId: (int) $unauthorized->getKey(), + userId: (int) $user->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class), app(PolicySyncService::class)); + + (new TenantSyncWorkerJob( + tenantId: 999999, + userId: (int) $user->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class), app(PolicySyncService::class)); + + $run = $run->fresh(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('completed'); + expect($run?->outcome)->toBe('partially_succeeded'); + + expect($run?->summary_counts['processed'] ?? null)->toBe(4); + expect($run?->summary_counts['succeeded'] ?? null)->toBe(1); + expect($run?->summary_counts['skipped'] ?? null)->toBe(2); + expect($run?->summary_counts['failed'] ?? null)->toBe(1); +})->group('ops-ux'); diff --git a/tests/Feature/PolicyCaptureSnapshotIdempotencyTest.php b/tests/Feature/PolicyCaptureSnapshotIdempotencyTest.php index 2cfb790..b47938c 100644 --- a/tests/Feature/PolicyCaptureSnapshotIdempotencyTest.php +++ b/tests/Feature/PolicyCaptureSnapshotIdempotencyTest.php @@ -2,9 +2,8 @@ use App\Filament\Resources\PolicyResource\Pages\ViewPolicy; use App\Jobs\CapturePolicySnapshotJob; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\Policy; -use App\Support\RunIdempotency; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; @@ -35,11 +34,9 @@ 'include_scope_tags' => true, ]); - $key = RunIdempotency::buildKey($tenant->getKey(), 'policy.capture_snapshot', $policy->getKey()); - - expect(BulkOperationRun::query() + expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('idempotency_key', $key) + ->where('type', 'policy.capture_snapshot') ->count())->toBe(1); Queue::assertPushed(CapturePolicySnapshotJob::class, 1); diff --git a/tests/Feature/PolicyCaptureSnapshotQueuedTest.php b/tests/Feature/PolicyCaptureSnapshotQueuedTest.php index 6b48906..e85bb50 100644 --- a/tests/Feature/PolicyCaptureSnapshotQueuedTest.php +++ b/tests/Feature/PolicyCaptureSnapshotQueuedTest.php @@ -2,7 +2,7 @@ use App\Filament\Resources\PolicyResource\Pages\ViewPolicy; use App\Jobs\CapturePolicySnapshotJob; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\Policy; use App\Services\Intune\VersionService; use Filament\Facades\Filament; @@ -36,13 +36,12 @@ Queue::assertPushed(CapturePolicySnapshotJob::class); - $run = BulkOperationRun::query() + $run = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('resource', 'policies') - ->where('action', 'capture_snapshot') + ->where('type', 'policy.capture_snapshot') ->latest('id') ->first(); expect($run)->not->toBeNull(); - expect($run->item_ids)->toBe([(string) $policy->getKey()]); + expect($run->context['policy_id'] ?? null)->toBe((int) $policy->getKey()); }); diff --git a/tests/Feature/RestoreAdapterTest.php b/tests/Feature/RestoreAdapterTest.php index 5fa645f..0863774 100644 --- a/tests/Feature/RestoreAdapterTest.php +++ b/tests/Feature/RestoreAdapterTest.php @@ -23,8 +23,8 @@ ->first(); expect($opRun)->not->toBeNull(); - expect($opRun?->status)->toBe('queued'); - expect($opRun?->outcome)->toBe('pending'); + expect($opRun?->status)->toBe('completed'); + expect($opRun?->outcome)->toBe('succeeded'); expect($opRun?->context)->toMatchArray([ 'restore_run_id' => (int) $restoreRun->getKey(), 'backup_set_id' => (int) $restoreRun->backup_set_id, @@ -34,7 +34,13 @@ it('updates the operation run when restore completes', function () { $restoreRun = RestoreRun::factory()->create([ - 'status' => RestoreRunStatus::Previewed->value, + 'status' => RestoreRunStatus::Queued->value, + 'metadata' => [ + 'total' => 3, + 'succeeded' => 1, + 'failed' => 1, + 'skipped' => 1, + ], ]); $opRun = OperationRun::query() @@ -48,12 +54,11 @@ $restoreRun->update([ 'status' => RestoreRunStatus::Completed->value, - 'results' => [ - 'assignment_outcomes' => [ - ['status' => 'success'], - ['status' => 'failed'], - ['status' => 'skipped'], - ], + 'metadata' => [ + 'total' => 3, + 'succeeded' => 1, + 'failed' => 1, + 'skipped' => 1, ], ]); @@ -62,16 +67,17 @@ expect($opRun->status)->toBe('completed'); expect($opRun->outcome)->toBe('succeeded'); expect($opRun->summary_counts)->toMatchArray([ - 'assignments_success' => 1, - 'assignments_failed' => 1, - 'assignments_skipped' => 1, + 'total' => 3, + 'succeeded' => 1, + 'failed' => 1, + 'skipped' => 1, ]); expect($opRun->completed_at)->not->toBeNull(); }); it('maps cancelled restore runs to failed outcome (cancelled is reserved)', function () { $restoreRun = RestoreRun::factory()->create([ - 'status' => RestoreRunStatus::Previewed->value, + 'status' => RestoreRunStatus::Queued->value, ]); $opRun = OperationRun::query() diff --git a/tests/Feature/RestoreAuditLoggingTest.php b/tests/Feature/RestoreAuditLoggingTest.php index 5a7ac53..f87c535 100644 --- a/tests/Feature/RestoreAuditLoggingTest.php +++ b/tests/Feature/RestoreAuditLoggingTest.php @@ -5,7 +5,6 @@ use App\Models\BackupSet; use App\Models\RestoreRun; use App\Models\Tenant; -use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreService; use App\Support\RestoreRunStatus; @@ -54,7 +53,7 @@ }); $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor'); - $job->handle($restoreService, app(AuditLogger::class), app(BulkOperationService::class)); + $job->handle($restoreService, app(AuditLogger::class)); $audit = AuditLog::query() ->where('tenant_id', $tenant->id) diff --git a/tests/Feature/RestoreRunRerunTest.php b/tests/Feature/RestoreRunRerunTest.php index 924a03a..319e9a9 100644 --- a/tests/Feature/RestoreRunRerunTest.php +++ b/tests/Feature/RestoreRunRerunTest.php @@ -7,6 +7,7 @@ use App\Models\Tenant; use App\Models\User; use Filament\Facades\Filament; +use Filament\Tables\Filters\TrashedFilter; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -71,3 +72,34 @@ expect($newRun->is_dry_run)->toBeTrue(); expect($newRun->requested_by)->toBe('tester@example.com'); }); + +test('rerun action is hidden for archived restore runs', function () { + $tenant = Tenant::factory()->create(); + + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 0, + ]); + + $run = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $run->delete(); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(ListRestoreRuns::class) + ->filterTable(TrashedFilter::class, false) + ->assertTableActionHidden('rerun', $run); +}); diff --git a/tests/Feature/RunAuthorizationTenantIsolationTest.php b/tests/Feature/RunAuthorizationTenantIsolationTest.php index 2190fb9..61d9851 100644 --- a/tests/Feature/RunAuthorizationTenantIsolationTest.php +++ b/tests/Feature/RunAuthorizationTenantIsolationTest.php @@ -1,26 +1,28 @@ create(); $tenantB = Tenant::factory()->create(); - BulkOperationRun::factory()->create([ + OperationRun::factory()->create([ 'tenant_id' => $tenantA->getKey(), - 'resource' => 'tenant_a', - 'action' => 'alpha', + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', ]); - BulkOperationRun::factory()->create([ + OperationRun::factory()->create([ 'tenant_id' => $tenantB->getKey(), - 'resource' => 'tenant_b', - 'action' => 'beta', + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', ]); $user = User::factory()->create(); @@ -30,20 +32,21 @@ ]); $this->actingAs($user) - ->get(BulkOperationRunResource::getUrl('index', tenant: $tenantA)) + ->get(OperationRunResource::getUrl('index', tenant: $tenantA)) ->assertOk() - ->assertSee('tenant_a') - ->assertDontSee('tenant_b'); + ->assertSee('Policy sync') + ->assertDontSee('Inventory sync'); }); -test('bulk operation run view is forbidden cross-tenant (403)', function () { +test('operation run view is not accessible cross-tenant', function () { $tenantA = Tenant::factory()->create(); $tenantB = Tenant::factory()->create(); - $runB = BulkOperationRun::factory()->create([ + $runB = OperationRun::factory()->create([ 'tenant_id' => $tenantB->getKey(), - 'resource' => 'tenant_b', - 'action' => 'beta', + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', ]); $user = User::factory()->create(); @@ -53,17 +56,18 @@ ]); $this->actingAs($user) - ->get(BulkOperationRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA)) - ->assertForbidden(); + ->get(OperationRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA)) + ->assertNotFound(); }); -test('readonly users can view bulk operation runs for their tenant', function () { +test('readonly users can view operation runs for their tenant', function () { $tenant = Tenant::factory()->create(); - $run = BulkOperationRun::factory()->create([ + $run = OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), - 'resource' => 'drift', - 'action' => 'generate', + 'type' => 'drift.generate', + 'status' => 'queued', + 'outcome' => 'pending', ]); $user = User::factory()->create(); @@ -72,15 +76,12 @@ ]); $this->actingAs($user) - ->get(BulkOperationRunResource::getUrl('index', tenant: $tenant)) + ->get(OperationRunResource::getUrl('index', tenant: $tenant)) ->assertOk() - ->assertSee('drift') - ->assertSee('generate'); + ->assertSee('Drift generation'); $this->actingAs($user) - ->get(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)) + ->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)) ->assertOk() - ->assertSee('drift') - ->assertSee('generate') - ->assertSee('Drift findings'); + ->assertSee('Drift generation'); }); diff --git a/tests/Feature/RunStartAuthorizationTest.php b/tests/Feature/RunStartAuthorizationTest.php index ac67c6c..cb1b577 100644 --- a/tests/Feature/RunStartAuthorizationTest.php +++ b/tests/Feature/RunStartAuthorizationTest.php @@ -1,8 +1,8 @@ where('tenant_id', $tenantB->id)->exists())->toBeFalse(); - expect(BulkOperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse(); + expect(OperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse(); }); diff --git a/tests/Unit/BulkBackupSetDeleteJobTest.php b/tests/Unit/BulkBackupSetDeleteJobTest.php index c921782..fc65583 100644 --- a/tests/Unit/BulkBackupSetDeleteJobTest.php +++ b/tests/Unit/BulkBackupSetDeleteJobTest.php @@ -1,12 +1,14 @@ createRun($tenant, $user, 'backup_set', 'delete', [$set->id], 1); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'backup_set.delete', + 'status' => 'running', + 'outcome' => 'pending', + 'context' => ['target_scope' => ['entra_tenant_id' => 'entra-test-tenant']], + 'summary_counts' => ['total' => 1, 'processed' => 0], + ]); - (new BulkBackupSetDeleteJob($run->id))->handle($service); + (new BackupSetDeleteWorkerJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + backupSetId: (int) $set->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class)); $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($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + expect((int) ($run->summary_counts['processed'] ?? 0))->toBe(1); + expect((int) ($run->summary_counts['succeeded'] ?? 0))->toBe(1); expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue(); @@ -72,17 +86,28 @@ 'requested_by' => 'tester@example.com', ]); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'backup_set', 'delete', [$set->id], 1); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'backup_set.delete', + 'status' => 'running', + 'outcome' => 'pending', + 'context' => ['target_scope' => ['entra_tenant_id' => 'entra-test-tenant']], + 'summary_counts' => ['total' => 1, 'processed' => 0], + ]); - (new BulkBackupSetDeleteJob($run->id))->handle($service); + (new BackupSetDeleteWorkerJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + backupSetId: (int) $set->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class)); $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($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + expect((int) ($run->summary_counts['processed'] ?? 0))->toBe(1); expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue(); expect(RestoreRun::query()->where('backup_set_id', $set->id)->exists())->toBeTrue(); diff --git a/tests/Unit/BulkBackupSetForceDeleteJobTest.php b/tests/Unit/BulkBackupSetForceDeleteJobTest.php index a0a381e..6304b22 100644 --- a/tests/Unit/BulkBackupSetForceDeleteJobTest.php +++ b/tests/Unit/BulkBackupSetForceDeleteJobTest.php @@ -1,13 +1,14 @@ delete(); - $service = app(BulkOperationService::class); - $run = BulkOperationRun::factory()->create([ - 'tenant_id' => $tenant->id, - 'user_id' => $user->id, - 'resource' => 'backup_set', - 'action' => 'force_delete', - 'status' => 'pending', - 'total_items' => 1, - 'item_ids' => [$set->id], - 'failures' => [], + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'backup_set.force_delete', + 'status' => 'running', + 'outcome' => 'pending', + 'context' => ['target_scope' => ['entra_tenant_id' => 'entra-test-tenant']], + 'summary_counts' => ['total' => 1, 'processed' => 0], + 'failure_summary' => [], ]); - (new BulkBackupSetForceDeleteJob($run->id))->handle($service); + (new BackupSetForceDeleteWorkerJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + backupSetId: (int) $set->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class)); expect(BackupSet::withTrashed()->find($set->id))->toBeNull(); expect(BackupItem::withTrashed()->find($item->id))->toBeNull(); $run->refresh(); - expect($run->status)->toBe('completed') - ->and($run->succeeded)->toBe(1) - ->and($run->skipped)->toBe(0) - ->and($run->failed)->toBe(0); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + expect((int) ($run->summary_counts['processed'] ?? 0))->toBe(1); + expect((int) ($run->summary_counts['succeeded'] ?? 0))->toBe(1); + expect((int) ($run->summary_counts['skipped'] ?? 0))->toBe(0); + expect((int) ($run->summary_counts['failed'] ?? 0))->toBe(0); }); test('bulk backup set force delete job skips sets referenced by restore runs', function () { @@ -80,26 +88,35 @@ 'requested_by' => 'tester@example.com', ]); - $service = app(BulkOperationService::class); - $run = BulkOperationRun::factory()->create([ - 'tenant_id' => $tenant->id, - 'user_id' => $user->id, - 'resource' => 'backup_set', - 'action' => 'force_delete', - 'status' => 'pending', - 'total_items' => 1, - 'item_ids' => [$set->id], - 'failures' => [], + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'backup_set.force_delete', + 'status' => 'running', + 'outcome' => 'pending', + 'context' => ['target_scope' => ['entra_tenant_id' => 'entra-test-tenant']], + 'summary_counts' => ['total' => 1, 'processed' => 0], + 'failure_summary' => [], ]); - (new BulkBackupSetForceDeleteJob($run->id))->handle($service); + (new BackupSetForceDeleteWorkerJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + backupSetId: (int) $set->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class)); $run->refresh(); - expect($run->status)->toBe('completed') - ->and($run->succeeded)->toBe(0) - ->and($run->skipped)->toBe(1) - ->and($run->failed)->toBe(0); + expect($run->status)->toBe('completed'); + expect((int) ($run->summary_counts['processed'] ?? 0))->toBe(1); + expect((int) ($run->summary_counts['succeeded'] ?? 0))->toBe(0); + expect((int) ($run->summary_counts['skipped'] ?? 0))->toBe(1); + expect((int) ($run->summary_counts['failed'] ?? 0))->toBe(0); expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue(); - expect(collect($run->failures)->pluck('reason')->all())->toContain('Referenced by restore runs'); + + $failure = collect($run->failure_summary ?? []) + ->first(fn ($entry) => is_array($entry) && ($entry['code'] ?? null) === 'backup_set.referenced_by_restore_runs'); + expect($failure)->not->toBeNull(); }); diff --git a/tests/Unit/BulkBackupSetRestoreJobTest.php b/tests/Unit/BulkBackupSetRestoreJobTest.php index 5c90980..d00ee66 100644 --- a/tests/Unit/BulkBackupSetRestoreJobTest.php +++ b/tests/Unit/BulkBackupSetRestoreJobTest.php @@ -1,12 +1,13 @@ trashed())->toBeTrue(); expect(BackupItem::withTrashed()->find($item->id)?->trashed())->toBeTrue(); - $service = app(BulkOperationService::class); - $run = BulkOperationRun::factory()->create([ - 'tenant_id' => $tenant->id, - 'user_id' => $user->id, - 'resource' => 'backup_set', - 'action' => 'restore', - 'status' => 'pending', - 'total_items' => 1, - 'item_ids' => [$set->id], - 'failures' => [], + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'backup_set.restore', + 'status' => 'running', + 'outcome' => 'pending', + 'context' => ['target_scope' => ['entra_tenant_id' => 'entra-test-tenant']], + 'summary_counts' => ['total' => 1, 'processed' => 0], + 'failure_summary' => [], ]); - (new BulkBackupSetRestoreJob($run->id))->handle($service); + (new BackupSetRestoreWorkerJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + backupSetId: (int) $set->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class)); $set->refresh(); expect($set->trashed())->toBeFalse(); @@ -59,10 +65,11 @@ expect($item->trashed())->toBeFalse(); $run->refresh(); - expect($run->status)->toBe('completed') - ->and($run->succeeded)->toBe(1) - ->and($run->skipped)->toBe(0) - ->and($run->failed)->toBe(0); + expect($run->status)->toBe('completed'); + expect((int) ($run->summary_counts['processed'] ?? 0))->toBe(1); + expect((int) ($run->summary_counts['succeeded'] ?? 0))->toBe(1); + expect((int) ($run->summary_counts['skipped'] ?? 0))->toBe(0); + expect((int) ($run->summary_counts['failed'] ?? 0))->toBe(0); }); test('bulk backup set restore job skips active sets', function () { @@ -76,28 +83,32 @@ 'item_count' => 0, ]); - $service = app(BulkOperationService::class); - $run = BulkOperationRun::factory()->create([ - 'tenant_id' => $tenant->id, - 'user_id' => $user->id, - 'resource' => 'backup_set', - 'action' => 'restore', - 'status' => 'pending', - 'total_items' => 1, - 'item_ids' => [$set->id], - 'failures' => [], + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'backup_set.restore', + 'status' => 'running', + 'outcome' => 'pending', + 'context' => ['target_scope' => ['entra_tenant_id' => 'entra-test-tenant']], + 'summary_counts' => ['total' => 1, 'processed' => 0], + 'failure_summary' => [], ]); - (new BulkBackupSetRestoreJob($run->id))->handle($service); + (new BackupSetRestoreWorkerJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + backupSetId: (int) $set->getKey(), + operationRun: $run, + ))->handle(app(OperationRunService::class), app(TargetScopeConcurrencyLimiter::class)); $set->refresh(); expect($set->trashed())->toBeFalse(); $run->refresh(); - expect($run->status)->toBe('completed') - ->and($run->succeeded)->toBe(0) - ->and($run->skipped)->toBe(1) - ->and($run->failed)->toBe(0); - - expect(collect($run->failures)->pluck('reason')->all())->toContain('Not archived'); + expect($run->status)->toBe('completed'); + expect((int) ($run->summary_counts['processed'] ?? 0))->toBe(1); + expect((int) ($run->summary_counts['succeeded'] ?? 0))->toBe(0); + expect((int) ($run->summary_counts['skipped'] ?? 0))->toBe(1); + expect((int) ($run->summary_counts['failed'] ?? 0))->toBe(0); }); diff --git a/tests/Unit/BulkOperationAbortMethodTest.php b/tests/Unit/BulkOperationAbortMethodTest.php index 27b5963..a5ad24a 100644 --- a/tests/Unit/BulkOperationAbortMethodTest.php +++ b/tests/Unit/BulkOperationAbortMethodTest.php @@ -1,23 +1,42 @@ create(); $user = User::factory()->create(); - $run = BulkOperationRun::factory()->create([ - 'tenant_id' => $tenant->id, - 'user_id' => $user->id, + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'policy.delete', 'status' => 'running', + 'outcome' => 'pending', + 'summary_counts' => [], + 'failure_summary' => [], ]); - app(BulkOperationService::class)->abort($run, 'threshold exceeded'); + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); - expect($run->refresh()->status)->toBe('aborted'); + $service->updateRun( + $run, + status: 'completed', + outcome: 'failed', + failures: [[ + 'code' => 'circuit_breaker', + 'message' => 'Threshold exceeded.', + ]], + ); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('failed'); + expect($run->failure_summary)->not->toBeEmpty(); }); diff --git a/tests/Unit/BulkOperationRunProgressTest.php b/tests/Unit/BulkOperationRunProgressTest.php index 0ea4e9d..0b3c67b 100644 --- a/tests/Unit/BulkOperationRunProgressTest.php +++ b/tests/Unit/BulkOperationRunProgressTest.php @@ -1,91 +1,93 @@ create(); - $user = User::factory()->create(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'delete', [1, 2, 3], 3); - - $service->start($run); - - $service->recordSuccess($run); - $service->recordSkipped($run); - $service->recordFailure($run, '3', 'Test failure'); - - $run->refresh(); - - expect($run->status)->toBe('running') - ->and($run->processed_items)->toBe(3) - ->and($run->succeeded)->toBe(1) - ->and($run->skipped)->toBe(1) - ->and($run->failed)->toBe(1) - ->and($run->failures)->toBeArray() - ->and($run->failures)->toHaveCount(1); -}); - -test('bulk operation run total_items is at least the item_ids count', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(); - - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'inventory', 'sync', ['a', 'b', 'c', 'd'], 1); - - expect($run->total_items)->toBe(4); -}); - -test('bulk operation completion clamps total_items up to processed_items', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(); - - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'inventory', 'sync', ['only-one'], 1); - - $service->start($run); - $service->recordSuccess($run); - $service->recordSuccess($run); - - $run->refresh(); - expect($run->processed_items)->toBe(2)->and($run->total_items)->toBe(1); - - $service->complete($run); - $run->refresh(); - - expect($run->total_items)->toBe(2); -}); - -test('bulk operation completion treats non-skipped failure entries as errors even when failed is zero', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(); - - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'backup_set', 'add_policies', ['1'], 1); - - $service->start($run); - $service->recordSuccess($run); - - $run->update([ - 'failures' => [ - [ - 'type' => 'foundation', - 'item_id' => 'foundation', - 'reason' => 'Forbidden', - 'reason_code' => 'graph_forbidden', - 'timestamp' => now()->toIso8601String(), - ], +test('operation run service increments whitelisted summary counts', function (): void { + $run = OperationRun::factory()->create([ + 'status' => 'queued', + 'outcome' => 'pending', + 'summary_counts' => [ + 'processed' => 0, + 'succeeded' => 0, + 'failed' => 0, + 'skipped' => 0, ], ]); - $service->complete($run); + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + $service->incrementSummaryCounts($run, [ + 'processed' => 3, + 'succeeded' => 1, + 'skipped' => 1, + 'failed' => 1, + 'secrets' => 999, + ]); $run->refresh(); - expect($run->failed)->toBe(0) - ->and($run->status)->toBe('completed_with_errors'); + expect($run->summary_counts)->toMatchArray([ + 'processed' => 3, + 'succeeded' => 1, + 'skipped' => 1, + 'failed' => 1, + ]); + expect($run->summary_counts)->not->toHaveKey('secrets'); +}); + +test('operation run service completes bulk runs based on summary counts', function (): void { + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + $succeeded = OperationRun::factory()->create([ + 'status' => 'running', + 'outcome' => 'pending', + 'summary_counts' => [ + 'total' => 3, + 'processed' => 3, + 'failed' => 0, + ], + ]); + + $service->maybeCompleteBulkRun($succeeded); + $succeeded->refresh(); + expect($succeeded->status)->toBe('completed') + ->and($succeeded->outcome)->toBe('succeeded'); + + $partial = OperationRun::factory()->create([ + 'status' => 'running', + 'outcome' => 'pending', + 'summary_counts' => [ + 'total' => 3, + 'processed' => 3, + 'failed' => 1, + ], + ]); + + $service->maybeCompleteBulkRun($partial); + $partial->refresh(); + expect($partial->status)->toBe('completed') + ->and($partial->outcome)->toBe('partially_succeeded'); + + $failed = OperationRun::factory()->create([ + 'status' => 'running', + 'outcome' => 'pending', + 'summary_counts' => [ + 'total' => 3, + 'processed' => 3, + 'failed' => 3, + ], + ]); + + $service->maybeCompleteBulkRun($failed); + $failed->refresh(); + expect($failed->status)->toBe('completed') + ->and($failed->outcome)->toBe('failed'); }); diff --git a/tests/Unit/BulkOperationRunStatusBucketTest.php b/tests/Unit/BulkOperationRunStatusBucketTest.php index 4ee4e1d..703ff18 100644 --- a/tests/Unit/BulkOperationRunStatusBucketTest.php +++ b/tests/Unit/BulkOperationRunStatusBucketTest.php @@ -1,70 +1,17 @@ create([ - 'resource' => 'drift', - 'action' => 'generate', - ]); - - expect($run->runType())->toBe('drift.generate'); +test('operation status normalizer maps queued and running', function (): void { + expect(OperationStatusNormalizer::toUxStatus('queued', 'pending'))->toBe('queued') + ->and(OperationStatusNormalizer::toUxStatus('running', 'pending'))->toBe('running'); }); -test('bulk operation statusBucket maps pending and running', function () { - $pending = BulkOperationRun::factory()->create(['status' => 'pending']); - $running = BulkOperationRun::factory()->create(['status' => 'running']); - - expect($pending->statusBucket())->toBe('queued') - ->and($running->statusBucket())->toBe('running'); -}); - -test('bulk operation statusBucket maps terminal outcomes using counts', function () { - $succeeded = BulkOperationRun::factory()->create([ - 'status' => 'completed', - 'succeeded' => 3, - 'failed' => 0, - ]); - - $partial = BulkOperationRun::factory()->create([ - 'status' => 'completed_with_errors', - 'succeeded' => 2, - 'failed' => 1, - ]); - - $failedWithErrors = BulkOperationRun::factory()->create([ - 'status' => 'completed_with_errors', - 'succeeded' => 0, - 'failed' => 4, - ]); - - $failedAfterProgress = BulkOperationRun::factory()->create([ - 'status' => 'failed', - 'succeeded' => 1, - 'failed' => 1, - ]); - - $partialWithNonCountedFailures = BulkOperationRun::factory()->create([ - 'status' => 'completed_with_errors', - 'succeeded' => 1, - 'failed' => 0, - 'failures' => [ - [ - 'type' => 'foundation', - 'item_id' => 'foundation', - 'reason' => 'Forbidden', - 'reason_code' => 'graph_forbidden', - 'timestamp' => now()->toIso8601String(), - ], - ], - ]); - - expect($succeeded->statusBucket())->toBe('succeeded') - ->and($partial->statusBucket())->toBe('partially succeeded') - ->and($failedWithErrors->statusBucket())->toBe('failed') - ->and($failedAfterProgress->statusBucket())->toBe('partially succeeded') - ->and($partialWithNonCountedFailures->statusBucket())->toBe('partially succeeded'); +test('operation status normalizer maps terminal outcomes', function (): void { + expect(OperationStatusNormalizer::toUxStatus('completed', 'succeeded'))->toBe('succeeded') + ->and(OperationStatusNormalizer::toUxStatus('completed', 'partially_succeeded'))->toBe('partial') + ->and(OperationStatusNormalizer::toUxStatus('completed', 'failed'))->toBe('failed') + ->and(OperationStatusNormalizer::toUxStatus('failed', 'pending'))->toBe('failed'); }); diff --git a/tests/Unit/BulkPolicyDeleteJobTest.php b/tests/Unit/BulkPolicyDeleteJobTest.php index 500c1cc..47f1310 100644 --- a/tests/Unit/BulkPolicyDeleteJobTest.php +++ b/tests/Unit/BulkPolicyDeleteJobTest.php @@ -1,11 +1,14 @@ count(3)->create(['tenant_id' => $tenant->id]); $policyIds = $policies->pluck('id')->toArray(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 3); + Bus::fake(); - $job = new BulkPolicyDeleteJob($run->id); - $job->handle($service); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'policy.delete', + 'status' => 'queued', + 'outcome' => 'pending', + 'context' => ['scope' => 'subset', 'policy_ids' => $policyIds], + 'summary_counts' => [], + ]); + + $job = new BulkPolicyDeleteJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $policyIds, + operationRun: $run, + ); + + $job->handle(app(OperationRunService::class)); $run->refresh(); - expect($run->status)->toBe('completed') - ->and($run->processed_items)->toBe(3) - ->and($run->succeeded)->toBe(3) - ->and($run->failed)->toBe(0); - $policies->each(function ($policy) { - expect($policy->refresh()->ignored_at)->not->toBeNull(); + expect($run->status)->toBe('running'); + expect((int) ($run->summary_counts['total'] ?? 0))->toBe(3); + + Bus::assertDispatched(PolicyBulkDeleteWorkerJob::class, 3); + Bus::assertDispatched(PolicyBulkDeleteWorkerJob::class, function (PolicyBulkDeleteWorkerJob $worker) use ($tenant, $user, $policyIds, $run) { + return $worker->tenantId === (int) $tenant->getKey() + && $worker->userId === (int) $user->getKey() + && in_array($worker->policyId, $policyIds, true) + && $worker->operationRun?->is($run); }); }); -test('job handles partial failures gracefully', function () { +test('job dispatches workers for all normalized IDs (including missing)', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(); $policies = Policy::factory()->count(2)->create(['tenant_id' => $tenant->id]); $policyIds = $policies->pluck('id')->toArray(); - // Add a non-existent ID $policyIds[] = 99999; - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 3); + Bus::fake(); - $job = new BulkPolicyDeleteJob($run->id); - $job->handle($service); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'policy.delete', + 'status' => 'queued', + 'outcome' => 'pending', + 'context' => ['scope' => 'subset', 'policy_ids' => $policyIds], + 'summary_counts' => [], + ]); + + $job = new BulkPolicyDeleteJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $policyIds, + operationRun: $run, + ); + + $job->handle(app(OperationRunService::class)); $run->refresh(); - expect($run->status)->toBe('completed_with_errors') - ->and($run->processed_items)->toBe(3) - ->and($run->succeeded)->toBe(2) - ->and($run->failed)->toBe(1); + expect($run->status)->toBe('running'); + expect((int) ($run->summary_counts['total'] ?? 0))->toBe(3); - expect($run->failures[0]['item_id'])->toBe('99999') - ->and($run->failures[0]['reason'])->toContain('not found'); + Bus::assertDispatched(PolicyBulkDeleteWorkerJob::class, 3); }); diff --git a/tests/Unit/BulkPolicyExportJobTest.php b/tests/Unit/BulkPolicyExportJobTest.php index 95299bf..1a28184 100644 --- a/tests/Unit/BulkPolicyExportJobTest.php +++ b/tests/Unit/BulkPolicyExportJobTest.php @@ -3,11 +3,12 @@ use App\Jobs\BulkPolicyExportJob; use App\Models\BackupItem; use App\Models\BackupSet; +use App\Models\OperationRun; use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; use App\Models\User; -use App\Services\BulkOperationService; +use App\Services\OperationRunService; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -31,16 +32,32 @@ $policyIds = $policies->pluck('id')->toArray(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'export', $policyIds, 3); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'policy.export', + 'status' => 'queued', + 'outcome' => 'pending', + 'context' => ['scope' => 'subset', 'policy_ids' => $policyIds], + 'summary_counts' => [], + 'failure_summary' => [], + ]); - $job = new BulkPolicyExportJob($run->id, 'My Bulk Backup'); - $job->handle($service); + $job = new BulkPolicyExportJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $policyIds, + backupName: 'My Bulk Backup', + operationRun: $run, + ); + $job->handle(app(OperationRunService::class)); $run->refresh(); expect($run->status)->toBe('completed') - ->and($run->processed_items)->toBe(3) - ->and($run->succeeded)->toBe(3); + ->and($run->outcome)->toBe('succeeded') + ->and((int) ($run->summary_counts['processed'] ?? 0))->toBe(3) + ->and((int) ($run->summary_counts['succeeded'] ?? 0))->toBe(3); // Verify BackupSet created $backupSet = BackupSet::where('name', 'My Bulk Backup')->first(); @@ -69,18 +86,34 @@ $policyIds = [$policyWithVersion->id, $policyNoVersion->id]; - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'export', $policyIds, 2); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'policy.export', + 'status' => 'queued', + 'outcome' => 'pending', + 'context' => ['scope' => 'subset', 'policy_ids' => $policyIds], + 'summary_counts' => [], + 'failure_summary' => [], + ]); - $job = new BulkPolicyExportJob($run->id, 'Partial Backup'); - $job->handle($service); + $job = new BulkPolicyExportJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $policyIds, + backupName: 'Partial Backup', + operationRun: $run, + ); + $job->handle(app(OperationRunService::class)); $run->refresh(); - expect($run->status)->toBe('completed_with_errors') - ->and($run->processed_items)->toBe(2) - ->and($run->succeeded)->toBe(1) - ->and($run->failed)->toBe(1); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('partially_succeeded'); + expect((int) ($run->summary_counts['processed'] ?? 0))->toBe(2); + expect((int) ($run->summary_counts['succeeded'] ?? 0))->toBe(1); + expect((int) ($run->summary_counts['failed'] ?? 0))->toBe(1); - expect($run->failures[0]['item_id'])->toBe((string) $policyNoVersion->id) - ->and($run->failures[0]['reason'])->toContain('No versions available'); + $failure = collect($run->failure_summary ?? [])->first(fn ($entry) => is_array($entry) && ($entry['code'] ?? null) === 'policy.no_versions'); + expect($failure)->not->toBeNull(); }); diff --git a/tests/Unit/BulkPolicyVersionForceDeleteJobTest.php b/tests/Unit/BulkPolicyVersionForceDeleteJobTest.php index d01217e..76fb892 100644 --- a/tests/Unit/BulkPolicyVersionForceDeleteJobTest.php +++ b/tests/Unit/BulkPolicyVersionForceDeleteJobTest.php @@ -1,15 +1,18 @@ create(); $user = User::factory()->create(); @@ -30,38 +33,85 @@ 'captured_at' => now()->subDays(10), ]); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', [$archived->id, $active->id], 2); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'policy_version.force_delete', + 'status' => 'running', + 'outcome' => 'pending', + 'summary_counts' => ['total' => 2], + 'failure_summary' => [], + 'context' => [ + 'target_scope' => ['directory_context_id' => 1], + ], + ]); - (new BulkPolicyVersionForceDeleteJob($run->id))->handle($service); + $runs = app(OperationRunService::class); + $limiter = app(TargetScopeConcurrencyLimiter::class); + + (new PolicyVersionForceDeleteWorkerJob( + tenantId: $tenant->id, + userId: $user->id, + policyVersionId: $archived->id, + operationRun: $run, + ))->handle($runs, $limiter); + + (new PolicyVersionForceDeleteWorkerJob( + tenantId: $tenant->id, + userId: $user->id, + policyVersionId: $active->id, + operationRun: $run, + ))->handle($runs, $limiter); $run->refresh(); expect($run->status)->toBe('completed') - ->and($run->processed_items)->toBe(2) - ->and($run->succeeded)->toBe(1) - ->and($run->skipped)->toBe(1) - ->and($run->failed)->toBe(0); + ->and($run->outcome)->toBe('succeeded') + ->and($run->summary_counts['total'] ?? null)->toBe(2) + ->and($run->summary_counts['processed'] ?? null)->toBe(2) + ->and($run->summary_counts['succeeded'] ?? null)->toBe(1) + ->and($run->summary_counts['skipped'] ?? null)->toBe(1) + ->and((int) ($run->summary_counts['failed'] ?? 0))->toBe(0); expect(PolicyVersion::withTrashed()->whereKey($archived->id)->exists())->toBeFalse(); expect($active->refresh()->trashed())->toBeFalse(); - - $reasons = collect($run->failures)->pluck('reason')->all(); - expect($reasons)->toContain('Not archived'); }); -test('job aborts when the failure threshold is exceeded', function () { +test('worker records failure when policy version is missing', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', [999999], 1); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'policy_version.force_delete', + 'status' => 'running', + 'outcome' => 'pending', + 'summary_counts' => ['total' => 1], + 'failure_summary' => [], + 'context' => [ + 'target_scope' => ['directory_context_id' => 1], + ], + ]); - (new BulkPolicyVersionForceDeleteJob($run->id))->handle($service); + $runs = app(OperationRunService::class); + $limiter = app(TargetScopeConcurrencyLimiter::class); + + (new PolicyVersionForceDeleteWorkerJob( + tenantId: $tenant->id, + userId: $user->id, + policyVersionId: 999999, + operationRun: $run, + ))->handle($runs, $limiter); $run->refresh(); - expect($run->status)->toBe('aborted') - ->and($run->failed)->toBe(1) - ->and($run->processed_items)->toBe(1); + expect($run->status)->toBe('completed') + ->and($run->outcome)->toBe('failed') + ->and($run->summary_counts['total'] ?? null)->toBe(1) + ->and($run->summary_counts['processed'] ?? null)->toBe(1) + ->and($run->summary_counts['failed'] ?? null)->toBe(1); + + $codes = collect($run->failure_summary ?? [])->pluck('code')->all(); + expect($codes)->toContain('policy_version.not_found'); }); diff --git a/tests/Unit/BulkPolicyVersionPruneJobTest.php b/tests/Unit/BulkPolicyVersionPruneJobTest.php index eedaa08..c01f62b 100644 --- a/tests/Unit/BulkPolicyVersionPruneJobTest.php +++ b/tests/Unit/BulkPolicyVersionPruneJobTest.php @@ -1,15 +1,17 @@ create(); $user = User::factory()->create(); @@ -41,53 +43,99 @@ 'captured_at' => now()->subDays(10), ]); - $service = app(BulkOperationService::class); - $run = $service->createRun( - $tenant, - $user, - 'policy_version', - 'prune', - [$eligible->id, $current->id, $tooRecent->id], - 3 - ); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'policy_version.prune', + 'status' => 'running', + 'outcome' => 'pending', + 'summary_counts' => ['total' => 3], + 'failure_summary' => [], + 'context' => [ + 'target_scope' => ['directory_context_id' => 1], + ], + ]); - (new BulkPolicyVersionPruneJob($run->id, 90))->handle($service); + $runs = app(OperationRunService::class); + $limiter = app(TargetScopeConcurrencyLimiter::class); + + (new PolicyVersionPruneWorkerJob( + tenantId: $tenant->id, + userId: $user->id, + policyVersionId: $eligible->id, + retentionDays: 90, + operationRun: $run, + ))->handle($runs, $limiter); + + (new PolicyVersionPruneWorkerJob( + tenantId: $tenant->id, + userId: $user->id, + policyVersionId: $current->id, + retentionDays: 90, + operationRun: $run, + ))->handle($runs, $limiter); + + (new PolicyVersionPruneWorkerJob( + tenantId: $tenant->id, + userId: $user->id, + policyVersionId: $tooRecent->id, + retentionDays: 90, + operationRun: $run, + ))->handle($runs, $limiter); $run->refresh(); expect($run->status)->toBe('completed') - ->and($run->processed_items)->toBe(3) - ->and($run->succeeded)->toBe(1) - ->and($run->skipped)->toBe(2) - ->and($run->failed)->toBe(0); + ->and($run->outcome)->toBe('succeeded') + ->and($run->summary_counts['total'] ?? null)->toBe(3) + ->and($run->summary_counts['processed'] ?? null)->toBe(3) + ->and($run->summary_counts['succeeded'] ?? null)->toBe(1) + ->and($run->summary_counts['skipped'] ?? null)->toBe(2) + ->and((int) ($run->summary_counts['failed'] ?? 0))->toBe(0); expect($eligible->refresh()->trashed())->toBeTrue(); expect($current->refresh()->trashed())->toBeFalse(); expect($tooRecent->refresh()->trashed())->toBeFalse(); - - expect($run->failures)->toBeArray(); - expect(collect($run->failures)->pluck('type')->all())->toContain('skipped'); - - $reasons = collect($run->failures)->pluck('reason')->all(); - expect($reasons)->toContain('Current version') - ->and($reasons)->toContain('Too recent'); }); -test('job records failure when version is missing', function () { +test('worker records failure when version is missing', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy_version', 'prune', [999999], 1); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'policy_version.prune', + 'status' => 'running', + 'outcome' => 'pending', + 'summary_counts' => ['total' => 1], + 'failure_summary' => [], + 'context' => [ + 'target_scope' => ['directory_context_id' => 1], + ], + ]); - (new BulkPolicyVersionPruneJob($run->id, 90))->handle($service); + $runs = app(OperationRunService::class); + $limiter = app(TargetScopeConcurrencyLimiter::class); + + (new PolicyVersionPruneWorkerJob( + tenantId: $tenant->id, + userId: $user->id, + policyVersionId: 999999, + retentionDays: 90, + operationRun: $run, + ))->handle($runs, $limiter); $run->refresh(); - expect($run->status)->toBe('aborted') - ->and($run->failed)->toBe(1) - ->and($run->processed_items)->toBe(1); + expect($run->status)->toBe('completed') + ->and($run->outcome)->toBe('failed') + ->and($run->summary_counts['failed'] ?? null)->toBe(1) + ->and($run->summary_counts['processed'] ?? null)->toBe(1); + + $codes = collect($run->failure_summary ?? [])->pluck('code')->all(); + expect($codes)->toContain('policy_version.not_found'); }); -test('job skips already archived versions instead of treating them as missing', function () { +test('worker skips already archived versions', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(); @@ -100,19 +148,36 @@ ]); $archived->delete(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy_version', 'prune', [$archived->id], 1); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'policy_version.prune', + 'status' => 'running', + 'outcome' => 'pending', + 'summary_counts' => ['total' => 1], + 'failure_summary' => [], + 'context' => [ + 'target_scope' => ['directory_context_id' => 1], + ], + ]); - (new BulkPolicyVersionPruneJob($run->id, 90))->handle($service); + $runs = app(OperationRunService::class); + $limiter = app(TargetScopeConcurrencyLimiter::class); + + (new PolicyVersionPruneWorkerJob( + tenantId: $tenant->id, + userId: $user->id, + policyVersionId: $archived->id, + retentionDays: 90, + operationRun: $run, + ))->handle($runs, $limiter); $run->refresh(); expect($run->status)->toBe('completed') - ->and($run->processed_items)->toBe(1) - ->and($run->succeeded)->toBe(0) - ->and($run->skipped)->toBe(1) - ->and($run->failed)->toBe(0); - - $reasons = collect($run->failures)->pluck('reason')->all(); - expect($reasons)->toContain('Already archived'); + ->and($run->outcome)->toBe('succeeded') + ->and($run->summary_counts['processed'] ?? null)->toBe(1) + ->and((int) ($run->summary_counts['succeeded'] ?? 0))->toBe(0) + ->and($run->summary_counts['skipped'] ?? null)->toBe(1) + ->and((int) ($run->summary_counts['failed'] ?? 0))->toBe(0); }); diff --git a/tests/Unit/BulkPolicyVersionRestoreJobTest.php b/tests/Unit/BulkPolicyVersionRestoreJobTest.php index 0faecf8..78c2b84 100644 --- a/tests/Unit/BulkPolicyVersionRestoreJobTest.php +++ b/tests/Unit/BulkPolicyVersionRestoreJobTest.php @@ -1,12 +1,12 @@ delete(); expect($version->trashed())->toBeTrue(); - $run = BulkOperationRun::factory()->create([ + $run = OperationRun::factory()->create([ 'tenant_id' => $tenant->id, 'user_id' => $user->id, - 'resource' => 'policy_version', - 'action' => 'restore', - 'status' => 'pending', - 'total_items' => 1, - 'item_ids' => [$version->id], - 'failures' => [], + 'type' => 'policy_version.restore', + 'status' => 'queued', + 'outcome' => 'pending', + 'summary_counts' => [], + 'failure_summary' => [], + 'context' => [ + 'target_scope' => ['directory_context_id' => 1], + ], ]); - (new BulkPolicyVersionRestoreJob($run->id))->handle(app(BulkOperationService::class)); + (new BulkPolicyVersionRestoreJob( + tenantId: $tenant->id, + userId: $user->id, + policyVersionIds: [$version->id], + operationRun: $run, + ))->handle(app(OperationRunService::class)); $version->refresh(); expect($version->trashed())->toBeFalse(); $run->refresh(); expect($run->status)->toBe('completed') - ->and($run->succeeded)->toBe(1) - ->and($run->skipped)->toBe(0) - ->and($run->failed)->toBe(0); + ->and($run->outcome)->toBe('succeeded') + ->and($run->summary_counts['total'] ?? null)->toBe(1) + ->and($run->summary_counts['processed'] ?? null)->toBe(1) + ->and($run->summary_counts['succeeded'] ?? null)->toBe(1) + ->and((int) ($run->summary_counts['skipped'] ?? 0))->toBe(0) + ->and((int) ($run->summary_counts['failed'] ?? 0))->toBe(0); }); test('bulk policy version restore skips active versions', function () { @@ -60,27 +70,35 @@ 'captured_at' => now()->subDays(120), ]); - $run = BulkOperationRun::factory()->create([ + $run = OperationRun::factory()->create([ 'tenant_id' => $tenant->id, 'user_id' => $user->id, - 'resource' => 'policy_version', - 'action' => 'restore', - 'status' => 'pending', - 'total_items' => 1, - 'item_ids' => [$version->id], - 'failures' => [], + 'type' => 'policy_version.restore', + 'status' => 'queued', + 'outcome' => 'pending', + 'summary_counts' => [], + 'failure_summary' => [], + 'context' => [ + 'target_scope' => ['directory_context_id' => 1], + ], ]); - (new BulkPolicyVersionRestoreJob($run->id))->handle(app(BulkOperationService::class)); + (new BulkPolicyVersionRestoreJob( + tenantId: $tenant->id, + userId: $user->id, + policyVersionIds: [$version->id], + operationRun: $run, + ))->handle(app(OperationRunService::class)); $version->refresh(); expect($version->trashed())->toBeFalse(); $run->refresh(); expect($run->status)->toBe('completed') - ->and($run->succeeded)->toBe(0) - ->and($run->skipped)->toBe(1) - ->and($run->failed)->toBe(0); - - expect(collect($run->failures)->pluck('reason')->all())->toContain('Not archived'); + ->and($run->outcome)->toBe('succeeded') + ->and($run->summary_counts['total'] ?? null)->toBe(1) + ->and($run->summary_counts['processed'] ?? null)->toBe(1) + ->and((int) ($run->summary_counts['succeeded'] ?? 0))->toBe(0) + ->and($run->summary_counts['skipped'] ?? null)->toBe(1) + ->and((int) ($run->summary_counts['failed'] ?? 0))->toBe(0); }); diff --git a/tests/Unit/BulkRestoreRunDeleteJobTest.php b/tests/Unit/BulkRestoreRunDeleteJobTest.php index b87f894..7ef9f0e 100644 --- a/tests/Unit/BulkRestoreRunDeleteJobTest.php +++ b/tests/Unit/BulkRestoreRunDeleteJobTest.php @@ -1,18 +1,37 @@ create(); $user = User::factory()->create(); + Bus::fake(); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'restore_run.delete', + 'status' => 'queued', + 'outcome' => 'pending', + 'summary_counts' => [], + 'failure_summary' => [], + 'context' => [ + 'target_scope' => ['directory_context_id' => 1], + ], + ]); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Backup', @@ -36,27 +55,107 @@ 'requested_by' => 'tester@example.com', ]); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'restore_run', 'delete', [$completed->id, $failed->id], 2); + (new BulkRestoreRunDeleteJob( + tenantId: $tenant->id, + userId: $user->id, + restoreRunIds: [$completed->id, $failed->id], + operationRun: $run, + ))->handle(app(OperationRunService::class)); - $job = new BulkRestoreRunDeleteJob($run->id); - $job->handle($service); + $run->refresh(); + + expect($run->status)->toBe('running') + ->and($run->summary_counts['total'] ?? null)->toBe(2); + + Bus::assertDispatchedTimes(RestoreRunDeleteWorkerJob::class, 2); +}); + +test('worker soft deletes deletable restore runs', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'restore_run.delete', + 'status' => 'running', + 'outcome' => 'pending', + 'summary_counts' => ['total' => 2], + 'failure_summary' => [], + 'context' => [ + 'target_scope' => ['directory_context_id' => 1], + ], + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $completed = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $failed = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'failed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $runs = app(OperationRunService::class); + $limiter = app(TargetScopeConcurrencyLimiter::class); + + (new RestoreRunDeleteWorkerJob( + tenantId: $tenant->id, + userId: $user->id, + restoreRunId: $completed->id, + operationRun: $run, + ))->handle($runs, $limiter); + + (new RestoreRunDeleteWorkerJob( + tenantId: $tenant->id, + userId: $user->id, + restoreRunId: $failed->id, + operationRun: $run, + ))->handle($runs, $limiter); $run->refresh(); expect($run->status)->toBe('completed') - ->and($run->processed_items)->toBe(2) - ->and($run->succeeded)->toBe(2) - ->and($run->failed)->toBe(0) - ->and($run->skipped)->toBe(0); + ->and($run->outcome)->toBe('succeeded') + ->and($run->summary_counts['processed'] ?? null)->toBe(2) + ->and($run->summary_counts['succeeded'] ?? null)->toBe(2) + ->and((int) ($run->summary_counts['failed'] ?? 0))->toBe(0) + ->and((int) ($run->summary_counts['skipped'] ?? 0))->toBe(0); expect(RestoreRun::withTrashed()->find($completed->id)?->trashed())->toBeTrue(); expect(RestoreRun::withTrashed()->find($failed->id)?->trashed())->toBeTrue(); }); -test('job skips non-deletable restore runs and records skip reasons', function () { +test('worker skips non-deletable restore runs', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => 'restore_run.delete', + 'status' => 'running', + 'outcome' => 'pending', + 'summary_counts' => ['total' => 1], + 'failure_summary' => [], + 'context' => [ + 'target_scope' => ['directory_context_id' => 1], + ], + ]); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Backup', @@ -72,21 +171,23 @@ 'requested_by' => 'tester@example.com', ]); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'restore_run', 'delete', [$running->id], 1); + $runs = app(OperationRunService::class); + $limiter = app(TargetScopeConcurrencyLimiter::class); - $job = new BulkRestoreRunDeleteJob($run->id); - $job->handle($service); + (new RestoreRunDeleteWorkerJob( + tenantId: $tenant->id, + userId: $user->id, + restoreRunId: $running->id, + operationRun: $run, + ))->handle($runs, $limiter); $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($run->failures[0]['type'] ?? null)->toBe('skipped'); - expect($run->failures[0]['reason'] ?? '')->toContain('Not deletable'); + ->and($run->outcome)->toBe('succeeded') + ->and($run->summary_counts['processed'] ?? null)->toBe(1) + ->and((int) ($run->summary_counts['succeeded'] ?? 0))->toBe(0) + ->and((int) ($run->summary_counts['failed'] ?? 0))->toBe(0) + ->and($run->summary_counts['skipped'] ?? null)->toBe(1); expect(RestoreRun::withTrashed()->find($running->id)?->trashed())->toBeFalse(); }); diff --git a/tests/Unit/BulkRestoreRunRestoreJobTest.php b/tests/Unit/BulkRestoreRunRestoreJobTest.php index 817c36d..a13a4e7 100644 --- a/tests/Unit/BulkRestoreRunRestoreJobTest.php +++ b/tests/Unit/BulkRestoreRunRestoreJobTest.php @@ -2,11 +2,11 @@ use App\Jobs\BulkRestoreRunRestoreJob; use App\Models\BackupSet; -use App\Models\BulkOperationRun; +use App\Models\OperationRun; use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; -use App\Services\BulkOperationService; +use App\Services\OperationRunService; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -32,27 +32,37 @@ $restoreRun->delete(); expect($restoreRun->trashed())->toBeTrue(); - $run = BulkOperationRun::factory()->create([ + $run = OperationRun::factory()->create([ 'tenant_id' => $tenant->id, 'user_id' => $user->id, - 'resource' => 'restore_run', - 'action' => 'restore', - 'status' => 'pending', - 'total_items' => 1, - 'item_ids' => [$restoreRun->id], - 'failures' => [], + 'type' => 'restore_run.restore', + 'status' => 'queued', + 'outcome' => 'pending', + 'summary_counts' => [], + 'failure_summary' => [], + 'context' => [ + 'target_scope' => ['directory_context_id' => 1], + ], ]); - (new BulkRestoreRunRestoreJob($run->id))->handle(app(BulkOperationService::class)); + (new BulkRestoreRunRestoreJob( + tenantId: $tenant->id, + userId: $user->id, + restoreRunIds: [$restoreRun->id], + operationRun: $run, + ))->handle(app(OperationRunService::class)); $restoreRun->refresh(); expect($restoreRun->trashed())->toBeFalse(); $run->refresh(); expect($run->status)->toBe('completed') - ->and($run->succeeded)->toBe(1) - ->and($run->skipped)->toBe(0) - ->and($run->failed)->toBe(0); + ->and($run->outcome)->toBe('succeeded') + ->and($run->summary_counts['total'] ?? null)->toBe(1) + ->and($run->summary_counts['processed'] ?? null)->toBe(1) + ->and($run->summary_counts['succeeded'] ?? null)->toBe(1) + ->and($run->summary_counts['skipped'] ?? null)->toBe(0) + ->and($run->summary_counts['failed'] ?? null)->toBe(0); }); test('bulk restore run restore skips active runs', function () { @@ -74,27 +84,35 @@ 'requested_by' => 'tester@example.com', ]); - $run = BulkOperationRun::factory()->create([ + $run = OperationRun::factory()->create([ 'tenant_id' => $tenant->id, 'user_id' => $user->id, - 'resource' => 'restore_run', - 'action' => 'restore', - 'status' => 'pending', - 'total_items' => 1, - 'item_ids' => [$restoreRun->id], - 'failures' => [], + 'type' => 'restore_run.restore', + 'status' => 'queued', + 'outcome' => 'pending', + 'summary_counts' => [], + 'failure_summary' => [], + 'context' => [ + 'target_scope' => ['directory_context_id' => 1], + ], ]); - (new BulkRestoreRunRestoreJob($run->id))->handle(app(BulkOperationService::class)); + (new BulkRestoreRunRestoreJob( + tenantId: $tenant->id, + userId: $user->id, + restoreRunIds: [$restoreRun->id], + operationRun: $run, + ))->handle(app(OperationRunService::class)); $restoreRun->refresh(); expect($restoreRun->trashed())->toBeFalse(); $run->refresh(); expect($run->status)->toBe('completed') - ->and($run->succeeded)->toBe(0) - ->and($run->skipped)->toBe(1) - ->and($run->failed)->toBe(0); - - expect(collect($run->failures)->pluck('reason')->all())->toContain('Not archived'); + ->and($run->outcome)->toBe('succeeded') + ->and($run->summary_counts['total'] ?? null)->toBe(1) + ->and($run->summary_counts['processed'] ?? null)->toBe(1) + ->and($run->summary_counts['succeeded'] ?? null)->toBe(0) + ->and($run->summary_counts['skipped'] ?? null)->toBe(1) + ->and($run->summary_counts['failed'] ?? null)->toBe(0); }); diff --git a/tests/Unit/CircuitBreakerTest.php b/tests/Unit/CircuitBreakerTest.php index 4619f8b..e59cd2a 100644 --- a/tests/Unit/CircuitBreakerTest.php +++ b/tests/Unit/CircuitBreakerTest.php @@ -1,41 +1,61 @@ create(); - $user = User::factory()->create(); - - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'delete', [100001, 100002, 100003, 100004, 100005, 100006, 100007, 100008, 100009, 100010], 10); - - (new BulkPolicyDeleteJob($run->id))->handle($service); - - $run->refresh(); - expect($run->status)->toBe('aborted') - ->and($run->failed)->toBe(6) - ->and($run->processed_items)->toBe(6); -}); - test('bulk export aborts when more than half of items fail', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(); - $service = app(BulkOperationService::class); - $run = $service->createRun($tenant, $user, 'policy', 'export', [200001, 200002, 200003, 200004, 200005, 200006, 200007, 200008, 200009, 200010], 10); + // 4 items total -> failure threshold is floor(4/2)=2, so 3 failures triggers circuit breaker. + $okPolicy = Policy::factory()->create(['tenant_id' => $tenant->id]); + PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $okPolicy->id, + 'policy_type' => $okPolicy->policy_type, + 'version_number' => 1, + 'snapshot' => ['ok' => true], + 'captured_at' => now(), + ]); - (new BulkPolicyExportJob($run->id, 'Circuit Breaker Backup'))->handle($service); + $failA = Policy::factory()->create(['tenant_id' => $tenant->id]); + $failB = Policy::factory()->create(['tenant_id' => $tenant->id]); + $failC = Policy::factory()->create(['tenant_id' => $tenant->id]); - $run->refresh(); - expect($run->status)->toBe('aborted') - ->and($run->failed)->toBe(6) - ->and($run->processed_items)->toBe(6); + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + $policyIds = [$okPolicy->id, $failA->id, $failB->id, $failC->id]; + $opRun = $service->ensureRun( + tenant: $tenant, + type: 'policy.export', + inputs: [ + 'scope' => 'subset', + 'policy_ids' => $policyIds, + ], + initiator: $user, + ); + + (new BulkPolicyExportJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: $policyIds, + backupName: 'Circuit Breaker Backup', + operationRun: $opRun, + ))->handle($service); + + $opRun->refresh(); + expect($opRun)->toBeInstanceOf(OperationRun::class); + expect($opRun->status)->toBe('completed'); + expect($opRun->outcome)->toBe('failed'); + expect($opRun->failure_summary)->not->toBeEmpty(); $this->assertDatabaseHas('backup_sets', [ 'tenant_id' => $tenant->id, diff --git a/tests/Unit/MicrosoftGraphClientRetryPolicyTest.php b/tests/Unit/MicrosoftGraphClientRetryPolicyTest.php new file mode 100644 index 0000000..ae0d23a --- /dev/null +++ b/tests/Unit/MicrosoftGraphClientRetryPolicyTest.php @@ -0,0 +1,28 @@ + 'https://graph.microsoft.com', + 'graph.version' => 'beta', + 'graph.retry.times' => 2, + 'graph.retry.sleep' => 0, + ]); + + Http::fakeSequence() + ->push(['error' => ['code' => 'TooManyRequests', 'message' => 'throttled']], 429) + ->push(['value' => []], 200); + + /** @var MicrosoftGraphClient $client */ + $client = app(MicrosoftGraphClient::class); + + $response = $client->request('GET', '/deviceManagement/managedDevices', [ + 'access_token' => 'test-access-token', + ]); + + expect($response->success)->toBeTrue(); + + Http::assertSentCount(2); +}); diff --git a/tests/Unit/Operations/BulkSelectionIdentityTest.php b/tests/Unit/Operations/BulkSelectionIdentityTest.php new file mode 100644 index 0000000..fc83b05 --- /dev/null +++ b/tests/Unit/Operations/BulkSelectionIdentityTest.php @@ -0,0 +1,44 @@ +fromIds(['b', 'a', 'a', ' ', 1]); + $b = $identity->fromIds([1, 'a', 'b']); + + expect($a['kind'])->toBe('ids'); + expect($a['ids_hash'])->toBeString(); + expect($a['ids_hash'])->toBe($b['ids_hash']); + expect($a['ids_count'])->toBe(3); +}); + +it('produces a stable query_hash regardless of associative key order', function (): void { + $identity = new BulkSelectionIdentity; + + $payloadA = [ + 'filters' => [ + 'status' => 'active', + 'type' => 'policy', + ], + 'search' => 'abc', + ]; + + $payloadB = [ + 'search' => 'abc', + 'filters' => [ + 'type' => 'policy', + 'status' => 'active', + ], + ]; + + $a = $identity->fromQuery($payloadA); + $b = $identity->fromQuery($payloadB); + + expect($a['kind'])->toBe('query'); + expect($a['query_hash'])->toBeString(); + expect($a['query_hash'])->toBe($b['query_hash']); +}); diff --git a/tests/Unit/RunIdempotencyTest.php b/tests/Unit/RunIdempotencyTest.php index bd189e0..18bc218 100644 --- a/tests/Unit/RunIdempotencyTest.php +++ b/tests/Unit/RunIdempotencyTest.php @@ -1,57 +1,22 @@ 2, 'a' => 1]); - $keyA2 = RunIdempotency::buildKey(1, 'policy.capture_snapshot', 'abc', ['a' => 1, 'b' => 2]); - $keyB = RunIdempotency::buildKey(1, 'policy.capture_snapshot', 'def', ['a' => 1, 'b' => 2]); +it('builds a deterministic 64 char sha256 fingerprint for bulk operations', function () { + /** @var BulkIdempotencyFingerprint $fingerprints */ + $fingerprints = app(BulkIdempotencyFingerprint::class); + + $selectionA = ['kind' => 'ids', 'ids_hash' => hash('sha256', 'a,b,c')]; + $selectionB = ['kind' => 'ids', 'ids_hash' => hash('sha256', 'a,b,d')]; + + $keyA1 = $fingerprints->build('policy.delete', ['entra_tenant_id' => 'tenant-a'], $selectionA); + $keyA2 = $fingerprints->build('policy.delete', ['entra_tenant_id' => 'tenant-a'], $selectionA); + $keyB = $fingerprints->build('policy.delete', ['entra_tenant_id' => 'tenant-a'], $selectionB); expect($keyA1)->toBe($keyA2) ->and($keyA1)->not->toBe($keyB) ->and($keyA1)->toMatch('/^[a-f0-9]{64}$/'); }); - -it('finds only active bulk operation runs by idempotency key', function () { - $pending = BulkOperationRun::factory()->create([ - 'idempotency_key' => RunIdempotency::buildKey(1, 'bulk.policy.capture_snapshot', 'abc'), - 'status' => 'pending', - ]); - - $completed = BulkOperationRun::factory()->create([ - 'tenant_id' => $pending->tenant_id, - 'user_id' => $pending->user_id, - 'idempotency_key' => $pending->idempotency_key, - 'status' => 'completed', - ]); - - expect(RunIdempotency::findActiveBulkOperationRun($pending->tenant_id, $pending->idempotency_key)) - ->not->toBeNull() - ->id->toBe($pending->id); - - expect(RunIdempotency::findActiveBulkOperationRun($pending->tenant_id, $completed->idempotency_key)) - ->id->toBe($pending->id); -}); - -it('finds only active restore runs by idempotency key', function () { - $active = RestoreRun::factory()->create([ - 'idempotency_key' => RunIdempotency::buildKey(1, 'restore.execute', 123), - 'status' => 'queued', - ]); - - RestoreRun::factory()->create([ - 'tenant_id' => $active->tenant_id, - 'backup_set_id' => $active->backup_set_id, - 'idempotency_key' => $active->idempotency_key, - 'status' => 'completed', - ]); - - expect(RunIdempotency::findActiveRestoreRun($active->tenant_id, $active->idempotency_key)) - ->not->toBeNull() - ->id->toBe($active->id); -});