056-remove-legacy-bulkops (#65)

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 <ahmeddarrazi@adsmac.local>
Reviewed-on: #65
This commit is contained in:
ahmido 2026-01-19 23:27:52 +00:00
parent bd6df1f343
commit a97beefda3
161 changed files with 9244 additions and 4620 deletions

View File

@ -10,6 +10,7 @@ ## Active Technologies
- PostgreSQL (JSONB) (feat/042-inventory-dependencies-graph) - PostgreSQL (JSONB) (feat/042-inventory-dependencies-graph)
- PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3 (feat/047-inventory-foundations-nodes) - 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 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) - PHP 8.4.15 (feat/005-bulk-operations)
@ -29,9 +30,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## 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/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/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
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->

View File

@ -0,0 +1,116 @@
<?php
namespace App\Console\Commands;
use App\Services\AdapterRunReconciler;
use Illuminate\Console\Command;
use Throwable;
class OpsReconcileAdapterRuns extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'ops:reconcile-adapter-runs
{--type= : Adapter run type (e.g. restore.execute)}
{--tenant= : Tenant ID}
{--older-than=60 : Only consider runs older than N minutes}
{--dry-run=true : Preview only (true/false)}
{--limit=50 : Max number of runs to inspect}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Reconcile stale adapter-backed operation runs from DB-only source-of-truth records.';
/**
* Execute the console command.
*/
public function handle()
{
try {
/** @var AdapterRunReconciler $reconciler */
$reconciler = app(AdapterRunReconciler::class);
$type = $this->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;
}
}
}

View File

@ -7,7 +7,7 @@
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun; use App\Models\BackupScheduleRun;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\BulkOperationRun; use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\RestoreRun; use App\Models\RestoreRun;
@ -88,7 +88,7 @@ public function handle(): int
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->delete(); ->delete();
BulkOperationRun::query() OperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->delete(); ->delete();
@ -152,7 +152,7 @@ private function countsForTenant(Tenant $tenant): array
return [ return [
'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(), 'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(),
'backup_schedules' => BackupSchedule::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(), 'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
'restore_runs' => RestoreRun::withTrashed()->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(), 'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),

View File

@ -5,8 +5,8 @@
use App\Models\BackupScheduleRun; use App\Models\BackupScheduleRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OpsUx\RunFailureSanitizer;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class TenantpilotReconcileBackupScheduleOperationRuns extends Command class TenantpilotReconcileBackupScheduleOperationRuns extends Command
@ -18,7 +18,7 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.'; 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'))); $tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
$olderThanMinutes = max(0, (int) $this->option('older-than')); $olderThanMinutes = max(0, (int) $this->option('older-than'));
@ -70,8 +70,8 @@ public function handle(OperationRunService $operationRunService, BulkOperationSe
outcome: 'failed', outcome: 'failed',
failures: [ failures: [
[ [
'code' => 'RUN_NOT_FOUND', 'code' => 'backup_schedule_run.not_found',
'message' => $bulkOperationService->sanitizeFailureReason('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) { $outcome = match ($scheduleRun->status) {
BackupScheduleRun::STATUS_SUCCESS => 'succeeded', BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded', BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
BackupScheduleRun::STATUS_SKIPPED, BackupScheduleRun::STATUS_SKIPPED => 'succeeded',
BackupScheduleRun::STATUS_CANCELED => 'cancelled', BackupScheduleRun::STATUS_CANCELED => 'failed',
default => 'failed', default => 'failed',
}; };
$summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : []; $summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : [];
$syncFailures = $summary['sync_failures'] ?? []; $syncFailures = $summary['sync_failures'] ?? [];
$summaryCounts = [ $policiesTotal = (int) ($summary['policies_total'] ?? 0);
'backup_schedule_id' => (int) $scheduleRun->backup_schedule_id, $policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0);
'backup_schedule_run_id' => (int) $scheduleRun->getKey(), $syncFailuresCount = is_array($syncFailures) ? count($syncFailures) : 0;
'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,
];
$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 = []; $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)) { if (filled($scheduleRun->error_message) || filled($scheduleRun->error_code)) {
$failures[] = [ $failures[] = [
'code' => (string) ($scheduleRun->error_code ?: 'BACKUP_SCHEDULE_ERROR'), 'code' => (string) ($scheduleRun->error_code ?: 'backup_schedule_run.error'),
'message' => $bulkOperationService->sanitizeFailureReason((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')), 'message' => RunFailureSanitizer::sanitizeMessage((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')),
]; ];
} }
@ -151,8 +165,8 @@ public function handle(OperationRunService $operationRunService, BulkOperationSe
} }
$failures[] = [ $failures[] = [
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR', 'code' => $status !== null ? "graph.http_{$status}" : 'graph.error',
'message' => $bulkOperationService->sanitizeFailureReason($message), 'message' => RunFailureSanitizer::sanitizeMessage($message),
]; ];
} }
} }

View File

@ -5,19 +5,17 @@
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Filament\Resources\InventorySyncRunResource; use App\Filament\Resources\InventorySyncRunResource;
use App\Jobs\GenerateDriftFindingsJob; use App\Jobs\GenerateDriftFindingsJob;
use App\Models\BulkOperationRun;
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventorySyncRun; use App\Models\InventorySyncRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Drift\DriftRunSelector; use App\Services\Drift\DriftRunSelector;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\RunIdempotency;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -50,8 +48,6 @@ class DriftLanding extends Page
public ?int $operationRunId = null; public ?int $operationRunId = null;
public ?int $bulkOperationRunId = null;
/** @var array<string, int>|null */ /** @var array<string, int>|null */
public ?array $statusCounts = null; public ?array $statusCounts = null;
@ -118,17 +114,6 @@ public function mount(): void
$this->operationRunId = (int) $existingOperationRun->getKey(); $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() $exists = Finding::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('finding_type', Finding::FINDING_TYPE_DRIFT)
@ -153,48 +138,39 @@ public function mount(): void
return; return;
} }
$latestRun = BulkOperationRun::query() $existingOperationRun?->refresh();
->where('tenant_id', $tenant->getKey())
->where('idempotency_key', $idempotencyKey)
->latest('id')
->first();
$activeRun = RunIdempotency::findActiveBulkOperationRun((int) $tenant->getKey(), $idempotencyKey); if ($existingOperationRun instanceof OperationRun
if ($activeRun instanceof BulkOperationRun) { && in_array($existingOperationRun->status, ['queued', 'running'], true)
) {
$this->state = 'generating'; $this->state = 'generating';
$this->bulkOperationRunId = (int) $activeRun->getKey(); $this->operationRunId = (int) $existingOperationRun->getKey();
return; return;
} }
if ($latestRun instanceof BulkOperationRun && $latestRun->status === 'completed') { if ($existingOperationRun instanceof OperationRun
$this->state = 'ready'; && $existingOperationRun->status === 'completed'
$this->bulkOperationRunId = (int) $latestRun->getKey(); ) {
$counts = is_array($existingOperationRun->summary_counts ?? null) ? $existingOperationRun->summary_counts : [];
$created = (int) ($counts['created'] ?? 0);
$newCount = (int) Finding::query() if ($existingOperationRun->outcome === 'failed') {
->where('tenant_id', $tenant->getKey()) $this->state = 'error';
->where('finding_type', Finding::FINDING_TYPE_DRIFT) $this->message = 'Drift generation failed for this comparison. See the run for details.';
->where('scope_key', $scopeKey) $this->operationRunId = (int) $existingOperationRun->getKey();
->where('baseline_run_id', $baseline->getKey())
->where('current_run_id', $current->getKey())
->where('status', Finding::STATUS_NEW)
->count();
$this->statusCounts = [Finding::STATUS_NEW => $newCount]; return;
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; 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)) { return;
$this->state = 'error'; }
$this->message = 'Drift generation failed for this comparison. See the run for details.';
$this->bulkOperationRunId = (int) $latestRun->getKey();
return;
} }
if (! $user->canSyncTenant($tenant)) { if (! $user->canSyncTenant($tenant)) {
@ -204,73 +180,60 @@ public function mount(): void
return; 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 */ /** @var OperationRunService $opService */
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
$opRun = $opService->enqueueBulkOperation(
tenant: $tenant, tenant: $tenant,
type: 'drift.generate', 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, 'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(), 'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(), 'current_run_id' => (int) $current->getKey(),
], ],
initiator: $user emitQueuedNotification: false,
); );
$this->operationRunId = (int) $opRun->getKey(); $this->operationRunId = (int) $opRun->getKey();
$this->state = 'generating';
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { if (! $opRun->wasRecentlyCreated) {
$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.
Notification::make() Notification::make()
->title('Drift generation already active') ->title('Drift generation already active')
->body('This operation is already queued or running.') ->body('This operation is already queued or running.')
->warning() ->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
->label('View Run') ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
return; 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); OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type) OperationUxPresenter::queuedToast((string) $opRun->type)

View File

@ -8,7 +8,6 @@
use App\Models\InventorySyncRun; use App\Models\InventorySyncRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService; use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
@ -112,7 +111,7 @@ protected function getHeaderActions(): array
return $user->canSyncTenant(Tenant::current()); 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(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
@ -202,22 +201,12 @@ protected function getHeaderActions(): array
$policyTypes = []; $policyTypes = [];
} }
$bulkRun = $bulkOperationService->createRun(
tenant: $tenant,
user: $user,
resource: 'inventory',
action: 'sync',
itemIds: $policyTypes,
totalItems: count($policyTypes),
);
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'inventory.sync.dispatched', action: 'inventory.sync.dispatched',
context: [ context: [
'metadata' => [ 'metadata' => [
'inventory_sync_run_id' => $run->id, 'inventory_sync_run_id' => $run->id,
'bulk_run_id' => $bulkRun->id,
'selection_hash' => $run->selection_hash, 'selection_hash' => $run->selection_hash,
], ],
], ],
@ -228,12 +217,11 @@ protected function getHeaderActions(): array
resourceId: (string) $run->id, 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( RunInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(), tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(), userId: (int) $user->getKey(),
inventorySyncRunId: (int) $run->id, inventorySyncRunId: (int) $run->id,
bulkRunId: (int) $bulkRun->getKey(),
operationRun: $opRun operationRun: $opRun
); );
}); });

View File

@ -13,7 +13,6 @@
use App\Rules\SupportedPolicyTypesRule; use App\Rules\SupportedPolicyTypesRule;
use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\ScheduleTimeService; use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
@ -397,23 +396,8 @@ public static function table(Table $table): Table
], ],
); );
$bulkRunId = null; $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
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));
}); });
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
@ -536,23 +520,8 @@ public static function table(Table $table): Table
], ],
); );
$bulkRunId = null; $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
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));
}); });
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
@ -591,16 +560,6 @@ public static function table(Table $table): Table
$operationRunService = app(OperationRunService::class); $operationRunService = app(OperationRunService::class);
$bulkRun = null; $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 = []; $createdRunIds = [];
@ -678,13 +637,12 @@ public static function table(Table $table): Table
'backup_schedule_run_id' => $run->id, 'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(), 'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'bulk_run_now', 'trigger' => 'bulk_run_now',
'bulk_run_id' => $bulkRun?->id,
], ],
], ],
); );
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void { $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun)); Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
}, emitQueuedNotification: false); }, emitQueuedNotification: false);
} }
@ -731,16 +689,6 @@ public static function table(Table $table): Table
$operationRunService = app(OperationRunService::class); $operationRunService = app(OperationRunService::class);
$bulkRun = null; $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 = []; $createdRunIds = [];
@ -818,13 +766,12 @@ public static function table(Table $table): Table
'backup_schedule_run_id' => $run->id, 'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(), 'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'bulk_retry', 'trigger' => 'bulk_retry',
'bulk_run_id' => $bulkRun?->id,
], ],
], ],
); );
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void { $operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun)); Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
}, emitQueuedNotification: false); }, emitQueuedNotification: false);
} }

View File

@ -9,9 +9,12 @@
use App\Jobs\BulkBackupSetRestoreJob; use App\Jobs\BulkBackupSetRestoreJob;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\BulkOperationService; use App\Models\User;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Intune\BackupService; use App\Services\Intune\BackupService;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
@ -198,22 +201,48 @@ public static function table(Table $table): Table
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class); if (! $tenant instanceof Tenant) {
$run = $service->createRun($tenant, $user, 'backup_set', 'delete', $ids, $count); return;
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);
} }
$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(), ->deselectRecordsAfterCompletion(),
@ -238,22 +267,48 @@ public static function table(Table $table): Table
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class); if (! $tenant instanceof Tenant) {
$run = $service->createRun($tenant, $user, 'backup_set', 'restore', $ids, $count); return;
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);
} }
$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(), ->deselectRecordsAfterCompletion(),
@ -293,22 +348,48 @@ public static function table(Table $table): Table
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class); if (! $tenant instanceof Tenant) {
$run = $service->createRun($tenant, $user, 'backup_set', 'force_delete', $ids, $count); return;
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);
} }
$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(), ->deselectRecordsAfterCompletion(),
]), ]),

View File

@ -1,388 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\BulkOperationRunResource\Pages;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\DatePicker;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class BulkOperationRunResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = BulkOperationRun::class;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Operations';
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->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<string, string>
*/
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;
}
}

View File

@ -1,11 +0,0 @@
<?php
namespace App\Filament\Resources\BulkOperationRunResource\Pages;
use App\Filament\Resources\BulkOperationRunResource;
use Filament\Resources\Pages\ListRecords;
class ListBulkOperationRuns extends ListRecords
{
protected static string $resource = BulkOperationRunResource::class;
}

View File

@ -1,11 +0,0 @@
<?php
namespace App\Filament\Resources\BulkOperationRunResource\Pages;
use App\Filament\Resources\BulkOperationRunResource;
use Filament\Resources\Pages\ViewRecord;
class ViewBulkOperationRun extends ViewRecord
{
protected static string $resource = BulkOperationRunResource::class;
}

View File

@ -37,6 +37,16 @@ class OperationRunResource extends Resource
protected static ?string $navigationLabel = 'Operations'; protected static ?string $navigationLabel = 'Operations';
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()?->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 public static function form(Schema $schema): Schema
{ {
return $schema; return $schema;
@ -58,6 +68,11 @@ public static function infolist(Schema $schema): Schema
->badge() ->badge()
->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)), ->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)),
TextEntry::make('initiator_name')->label('Initiator'), 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') TextEntry::make('elapsed')
->label('Elapsed') ->label('Elapsed')
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)), ->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
@ -238,13 +253,6 @@ public static function table(Table $table): Table
->bulkActions([]); ->bulkActions([]);
} }
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with('user')
->latest('id');
}
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
@ -273,4 +281,42 @@ private static function outcomeColor(?string $outcome): string
default => 'gray', 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;
}
} }

View File

@ -11,9 +11,9 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\PolicyNormalizer;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
@ -391,7 +391,7 @@ public static function table(Table $table): Table
return $user->canSyncTenant($tenant); return $user->canSyncTenant($tenant);
}) })
->action(function (Policy $record) { ->action(function (Policy $record, HasTable $livewire): void {
$tenant = Tenant::current(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
@ -458,10 +458,51 @@ public static function table(Table $table): Table
$tenant = Tenant::current(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
$service = app(BulkOperationService::class); if (! $user instanceof User) {
$run = $service->createRun($tenant, $user, 'policy', 'export', [$record->id], 1); 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'), ])->icon('heroicon-o-ellipsis-vertical'),
]) ])
@ -499,24 +540,54 @@ public static function table(Table $table): Table
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class); if (! $user instanceof User) {
$run = $service->createRun($tenant, $user, 'policy', 'delete', $ids, $count); return;
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);
} }
/** @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(), ->deselectRecordsAfterCompletion(),
@ -537,8 +608,50 @@ public static function table(Table $table): Table
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class); if (! $user instanceof User) {
$run = $service->createRun($tenant, $user, 'policy', 'unignore', $ids, $count); 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) { if ($count >= 20) {
Notification::make() Notification::make()
@ -550,11 +663,17 @@ public static function table(Table $table): Table
->duration(8000) ->duration(8000)
->sendToDatabase($user) ->sendToDatabase($user)
->send(); ->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(), ->deselectRecordsAfterCompletion(),
@ -581,7 +700,7 @@ public static function table(Table $table): Table
return $value === 'ignored'; return $value === 'ignored';
}) })
->action(function (Collection $records) { ->action(function (Collection $records, HasTable $livewire): void {
$tenant = Tenant::current(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
@ -660,8 +779,53 @@ public static function table(Table $table): Table
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class); if (! $user instanceof User) {
$run = $service->createRun($tenant, $user, 'policy', 'export', $ids, $count); 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) { if ($count >= 20) {
Notification::make() Notification::make()
@ -673,11 +837,15 @@ public static function table(Table $table): Table
->duration(8000) ->duration(8000)
->sendToDatabase($user) ->sendToDatabase($user)
->send(); ->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(), ->deselectRecordsAfterCompletion(),
]), ]),

View File

@ -2,12 +2,12 @@
namespace App\Filament\Resources\PolicyResource\Pages; namespace App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\BulkOperationRunResource;
use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource;
use App\Jobs\CapturePolicySnapshotJob; 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\OpsUx\OperationUxPresenter;
use App\Support\RunIdempotency;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms; use Filament\Forms;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -54,64 +54,66 @@ protected function getActions(): array
return; return;
} }
$idempotencyKey = RunIdempotency::buildKey( /** @var BulkSelectionIdentity $selection */
tenantId: $tenant->getKey(), $selection = app(BulkSelectionIdentity::class);
operationType: 'policy.capture_snapshot', $selectionIdentity = $selection->fromIds([(string) $policy->getKey()]);
targetId: $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( if (! $opRun->wasRecentlyCreated) {
tenantId: $tenant->getKey(),
idempotencyKey: $idempotencyKey
);
if ($existingRun) {
Notification::make() Notification::make()
->title('Snapshot already in progress') ->title('Snapshot already in progress')
->body('An active run already exists for this policy. Opening run details.') ->body('An active run already exists for this policy. Opening run details.')
->actions([ ->actions([
\Filament\Actions\Action::make('view_run') \Filament\Actions\Action::make('view_run')
->label('View run') ->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->info() ->info()
->send(); ->send();
$this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)); $this->redirect(OperationRunLinks::view($opRun, $tenant));
return; 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') OperationUxPresenter::queuedToast('policy.capture_snapshot')
->actions([ ->actions([
\Filament\Actions\Action::make('view_run') \Filament\Actions\Action::make('view_run')
->label('View run') ->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
$this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)); $this->redirect(OperationRunLinks::view($opRun, $tenant));
}) })
->color('primary'), ->color('primary'),
]; ];

View File

@ -10,10 +10,13 @@
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\BulkOperationService; use App\Models\User;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\PolicyNormalizer;
use App\Services\Intune\VersionDiff; use App\Services\Intune\VersionDiff;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use BackedEnum; use BackedEnum;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
@ -406,22 +409,66 @@ public static function table(Table $table): Table
$retentionDays = (int) ($data['retention_days'] ?? 90); $retentionDays = (int) ($data['retention_days'] ?? 90);
$service = app(BulkOperationService::class); if (! $tenant instanceof Tenant) {
$run = $service->createRun($tenant, $user, 'policy_version', 'prune', $ids, $count); return;
}
if ($count >= 20) { $initiator = $user instanceof User ? $user : null;
OperationUxPresenter::queuedToast('policy_version.prune')
/** @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([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->duration(8000)
->sendToDatabase($initiator);
BulkPolicyVersionPruneJob::dispatch($run->id, $retentionDays);
} else {
BulkPolicyVersionPruneJob::dispatchSync($run->id, $retentionDays);
} }
OperationUxPresenter::queuedToast('policy_version.prune')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
}) })
->deselectRecordsAfterCompletion(), ->deselectRecordsAfterCompletion(),
@ -446,22 +493,48 @@ public static function table(Table $table): Table
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class); if (! $tenant instanceof Tenant) {
$run = $service->createRun($tenant, $user, 'policy_version', 'restore', $ids, $count); return;
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);
} }
$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(), ->deselectRecordsAfterCompletion(),
@ -495,22 +568,64 @@ public static function table(Table $table): Table
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class); if (! $tenant instanceof Tenant) {
$run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', $ids, $count); return;
}
if ($count >= 20) { $initiator = $user instanceof User ? $user : null;
OperationUxPresenter::queuedToast('policy_version.force_delete')
/** @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([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->duration(8000)
->sendToDatabase($initiator);
BulkPolicyVersionForceDeleteJob::dispatch($run->id);
} else {
BulkPolicyVersionForceDeleteJob::dispatchSync($run->id);
} }
OperationUxPresenter::queuedToast('policy_version.force_delete')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
}) })
->deselectRecordsAfterCompletion(), ->deselectRecordsAfterCompletion(),
]), ]),

View File

@ -12,17 +12,20 @@
use App\Models\EntraGroup; use App\Models\EntraGroup;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Rules\SkipOrUuidRule; use App\Rules\SkipOrUuidRule;
use App\Services\BulkOperationService;
use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreDiffGenerator; use App\Services\Intune\RestoreDiffGenerator;
use App\Services\Intune\RestoreRiskChecker; use App\Services\Intune\RestoreRiskChecker;
use App\Services\Intune\RestoreService; 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\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\RestoreRunIdempotency;
use App\Support\RestoreRunStatus; use App\Support\RestoreRunStatus;
use App\Support\RunIdempotency;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
@ -738,7 +741,8 @@ public static function table(Table $table): Table
->visible(function (RestoreRun $record): bool { ->visible(function (RestoreRun $record): bool {
$backupSet = $record->backupSet; $backupSet = $record->backupSet;
return $record->isDeletable() return ! $record->trashed()
&& $record->isDeletable()
&& $backupSet !== null && $backupSet !== null
&& ! $backupSet->trashed(); && ! $backupSet->trashed();
}) })
@ -751,10 +755,10 @@ public static function table(Table $table): Table
$tenant = $record->tenant; $tenant = $record->tenant;
$backupSet = $record->backupSet; $backupSet = $record->backupSet;
if (! $tenant || ! $backupSet || $backupSet->trashed()) { if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) {
Notification::make() Notification::make()
->title('Restore run cannot be rerun') ->title('Restore run cannot be rerun')
->body('Backup set is archived or unavailable.') ->body('Restore run or backup set is archived or unavailable.')
->warning() ->warning()
->send(); ->send();
@ -780,14 +784,14 @@ public static function table(Table $table): Table
'rerun_of_restore_run_id' => $record->id, 'rerun_of_restore_run_id' => $record->id,
]; ];
$idempotencyKey = RunIdempotency::restoreExecuteKey( $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(), tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(), backupSetId: (int) $backupSet->getKey(),
selectedItemIds: $selectedItemIds, selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping, groupMapping: $groupMapping,
); );
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) { if ($existing) {
Notification::make() Notification::make()
@ -813,7 +817,7 @@ public static function table(Table $table): Table
'group_mapping' => $groupMapping !== [] ? $groupMapping : null, 'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]); ]);
} catch (QueryException $exception) { } catch (QueryException $exception) {
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) { if ($existing) {
Notification::make() Notification::make()
@ -1016,7 +1020,6 @@ public static function table(Table $table): Table
return [ return [
Forms\Components\TextInput::make('confirmation') Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm') ->label('Type DELETE to confirm')
->required()
->in(['DELETE']) ->in(['DELETE'])
->validationMessages([ ->validationMessages([
'in' => 'Please type DELETE to confirm.', 'in' => 'Please type DELETE to confirm.',
@ -1032,24 +1035,48 @@ public static function table(Table $table): Table
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class); if (! $tenant instanceof Tenant) {
$run = $service->createRun($tenant, $user, 'restore_run', 'delete', $ids, $count); return;
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);
} }
$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(), ->deselectRecordsAfterCompletion(),
@ -1074,24 +1101,59 @@ public static function table(Table $table): Table
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class); if (! $tenant instanceof Tenant) {
$run = $service->createRun($tenant, $user, 'restore_run', 'restore', $ids, $count); return;
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);
} }
$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(), ->deselectRecordsAfterCompletion(),
@ -1125,24 +1187,59 @@ public static function table(Table $table): Table
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class); if (! $tenant instanceof Tenant) {
$run = $service->createRun($tenant, $user, 'restore_run', 'force_delete', $ids, $count); return;
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);
} }
$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(), ->deselectRecordsAfterCompletion(),
]), ]),
@ -1484,14 +1581,14 @@ public static function createRestoreRun(array $data): RestoreRun
$metadata['preview_ran_at'] = $previewRanAt; $metadata['preview_ran_at'] = $previewRanAt;
} }
$idempotencyKey = RunIdempotency::restoreExecuteKey( $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(), tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(), backupSetId: (int) $backupSet->getKey(),
selectedItemIds: $selectedItemIds, selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping, groupMapping: $groupMapping,
); );
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) { if ($existing) {
Notification::make() Notification::make()
@ -1517,7 +1614,7 @@ public static function createRestoreRun(array $data): RestoreRun
'group_mapping' => $groupMapping !== [] ? $groupMapping : null, 'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]); ]);
} catch (QueryException $exception) { } catch (QueryException $exception) {
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) { if ($existing) {
Notification::make() Notification::make()

View File

@ -8,7 +8,6 @@
use App\Jobs\SyncPoliciesJob; use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
@ -17,6 +16,7 @@
use App\Services\Intune\TenantConfigService; use App\Services\Intune\TenantConfigService;
use App\Services\Intune\TenantPermissionService; use App\Services\Intune\TenantPermissionService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
@ -448,49 +448,42 @@ public static function table(Table $table): Table
$ids = $eligible->pluck('id')->toArray(); $ids = $eligible->pluck('id')->toArray();
$count = $eligible->count(); $count = $eligible->count();
$service = app(BulkOperationService::class); /** @var BulkSelectionIdentity $selection */
$run = $service->createRun($tenantContext, $user, 'tenant', 'sync', $ids, $count); $selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
foreach ($eligible as $tenant) { /** @var OperationRunService $runs */
// Note: We might want canonical runs for bulk syncs too, but spec Phase 1 mentions tenant-scoped operations. $runs = app(OperationRunService::class);
// 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 $opService */ $opRun = $runs->enqueueBulkOperation(
$opService = app(OperationRunService::class); tenant: $tenantContext,
$opRun = $opService->ensureRun( type: 'tenant.sync',
tenant: $tenant, targetScope: [
type: 'policy.sync', 'entra_tenant_id' => (string) ($tenantContext->tenant_id ?? $tenantContext->external_id),
inputs: ['scope' => 'full', 'bulk_run_id' => $run->id], ],
initiator: $user 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); OperationUxPresenter::queuedToast('tenant.sync')
->actions([
$auditLogger->log( Actions\Action::make('view_run')
tenant: $tenant, ->label('View run')
action: 'tenant.sync_dispatched', ->url(OperationRunLinks::view($opRun, $tenantContext)),
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)
->send(); ->send();
BulkTenantSyncJob::dispatch($run->id);
}) })
->deselectRecordsAfterCompletion(), ->deselectRecordsAfterCompletion(),
]) ])

View File

@ -2,20 +2,19 @@
namespace App\Jobs; namespace App\Jobs;
use App\Filament\Resources\BulkOperationRunResource;
use App\Jobs\Middleware\TrackOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\BulkOperationService; use App\Models\User;
use App\Services\Intune\FoundationSnapshotService; use App\Services\Intune\FoundationSnapshotService;
use App\Services\Intune\PolicyCaptureOrchestrator; use App\Services\Intune\PolicyCaptureOrchestrator;
use App\Services\Intune\SnapshotValidator; use App\Services\Intune\SnapshotValidator;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\RunFailureSanitizer;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -34,12 +33,17 @@ class AddPoliciesToBackupSetJob implements ShouldQueue
public ?OperationRun $operationRun = null; public ?OperationRun $operationRun = null;
/**
* @param array<int, int> $policyIds
* @param array{include_assignments?: bool, include_scope_tags?: bool, include_foundations?: bool} $options
*/
public function __construct( public function __construct(
public int $bulkRunId, public int $tenantId,
public int $userId,
public int $backupSetId, public int $backupSetId,
public bool $includeAssignments, public array $policyIds,
public bool $includeScopeTags, public array $options,
public bool $includeFoundations, public string $idempotencyKey,
?OperationRun $operationRun = null ?OperationRun $operationRun = null
) { ) {
$this->operationRun = $operationRun; $this->operationRun = $operationRun;
@ -51,39 +55,31 @@ public function middleware(): array
} }
public function handle( public function handle(
BulkOperationService $bulkOperationService, OperationRunService $operationRunService,
PolicyCaptureOrchestrator $captureOrchestrator, PolicyCaptureOrchestrator $captureOrchestrator,
FoundationSnapshotService $foundationSnapshots, FoundationSnapshotService $foundationSnapshots,
SnapshotValidator $snapshotValidator, SnapshotValidator $snapshotValidator,
): void { ): void {
$run = BulkOperationRun::with(['tenant', 'user'])->find($this->bulkRunId); if (! $this->operationRun instanceof OperationRun) {
if (! $run) {
return; return;
} }
$started = BulkOperationRun::query() $tenant = Tenant::query()->find($this->tenantId);
->whereKey($run->getKey()) $initiator = User::query()->find($this->userId);
->where('status', 'pending')
->update(['status' => 'running']);
if ($started === 0) { $policyIds = $this->normalizePolicyIds($this->policyIds);
return; $includeAssignments = (bool) ($this->options['include_assignments'] ?? false);
} $includeScopeTags = (bool) ($this->options['include_scope_tags'] ?? false);
$includeFoundations = (bool) ($this->options['include_foundations'] ?? false);
$run->refresh();
$tenant = $run->tenant ?? Tenant::query()->find($run->tenant_id);
try { try {
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
$this->markRunFailed( $this->failRun(
bulkOperationService: $bulkOperationService, operationRunService: $operationRunService,
run: $run,
tenant: null, tenant: null,
itemId: (string) $this->backupSetId, code: 'tenant.not_found',
reasonCode: 'unknown', message: 'Tenant not found for run.',
reason: 'Tenant not found for run.', initiator: $initiator,
); );
return; return;
@ -95,49 +91,64 @@ public function handle(
->first(); ->first();
if (! $backupSet) { if (! $backupSet) {
$this->markRunFailed( $this->failRun(
bulkOperationService: $bulkOperationService, operationRunService: $operationRunService,
run: $run,
tenant: $tenant, tenant: $tenant,
itemId: (string) $this->backupSetId, code: 'backup_set.not_found',
reasonCode: 'backup_set_not_found', message: 'Backup set not found.',
reason: 'Backup set not found.', initiator: $initiator,
); );
return; return;
} }
if ($backupSet->trashed()) { if ($backupSet->trashed()) {
$this->markRunFailed( $this->failRun(
bulkOperationService: $bulkOperationService, operationRunService: $operationRunService,
run: $run,
tenant: $tenant, tenant: $tenant,
itemId: (string) $backupSet->getKey(), code: 'backup_set.archived',
reasonCode: 'backup_set_archived', message: 'Backup set is archived.',
reason: 'Backup set is archived.', initiator: $initiator,
); );
return; 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 === []) { if ($policyIds === []) {
$bulkOperationService->complete($run); $operationRunService->updateRun(
$this->operationRun,
status: 'completed',
outcome: 'failed',
failures: [[
'code' => 'selection.empty',
'message' => 'No policies selected.',
]],
);
if ($this->operationRun) { $this->notifyRunFailed($initiator, $tenant, 'No policies selected.');
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->updateRun($this->operationRun, 'completed', 'succeeded', ['policy_ids' => 0]);
}
return; return;
} }
if ((int) $run->total_items !== count($policyIds)) {
$run->update(['total_items' => count($policyIds)]);
}
$existingBackupFailures = (array) Arr::get($backupSet->metadata ?? [], 'failures', []); $existingBackupFailures = (array) Arr::get($backupSet->metadata ?? [], 'failures', []);
$newBackupFailures = []; $newBackupFailures = [];
@ -172,14 +183,14 @@ public function handle(
->get() ->get()
->keyBy('id'); ->keyBy('id');
$runFailuresForOperationRun = [];
foreach ($policyIds as $policyId) { foreach ($policyIds as $policyId) {
if (isset($activePolicyIdSet[$policyId])) { if (isset($activePolicyIdSet[$policyId])) {
$bulkOperationService->recordSkippedWithReason( $operationRunService->incrementSummaryCounts($this->operationRun, [
run: $run, 'processed' => 1,
itemId: (string) $policyId, 'skipped' => 1,
reason: 'Already in backup set', ]);
reasonCode: 'already_in_backup_set',
);
continue; continue;
} }
@ -193,7 +204,11 @@ public function handle(
$didMutateBackupSet = true; $didMutateBackupSet = true;
$backupSetItemMutations++; $backupSetItemMutations++;
$bulkOperationService->recordSuccess($run); $operationRunService->incrementSummaryCounts($this->operationRun, [
'processed' => 1,
'succeeded' => 1,
'updated' => 1,
]);
continue; continue;
} }
@ -203,29 +218,30 @@ public function handle(
if (! $policy instanceof Policy) { if (! $policy instanceof Policy) {
$newBackupFailures[] = [ $newBackupFailures[] = [
'policy_id' => $policyId, 'policy_id' => $policyId,
'reason' => $bulkOperationService->sanitizeFailureReason('Policy not found.'), 'reason' => RunFailureSanitizer::sanitizeMessage('Policy not found.'),
'status' => null, 'status' => null,
'reason_code' => 'policy_not_found', 'reason_code' => 'policy_not_found',
]; ];
$didMutateBackupSet = true; $didMutateBackupSet = true;
$bulkOperationService->recordFailure( $operationRunService->incrementSummaryCounts($this->operationRun, [
run: $run, 'processed' => 1,
itemId: (string) $policyId, 'failed' => 1,
reason: 'Policy not found.', ]);
reasonCode: 'policy_not_found',
); $runFailuresForOperationRun[] = [
'code' => 'policy.not_found',
'message' => "Policy {$policyId} not found.",
];
continue; continue;
} }
if ($policy->ignored_at) { if ($policy->ignored_at) {
$bulkOperationService->recordSkippedWithReason( $operationRunService->incrementSummaryCounts($this->operationRun, [
run: $run, 'processed' => 1,
itemId: (string) $policyId, 'skipped' => 1,
reason: 'Policy is ignored locally', ]);
reasonCode: 'policy_ignored',
);
continue; continue;
} }
@ -234,16 +250,16 @@ public function handle(
$captureResult = $captureOrchestrator->capture( $captureResult = $captureOrchestrator->capture(
policy: $policy, policy: $policy,
tenant: $tenant, tenant: $tenant,
includeAssignments: $this->includeAssignments, includeAssignments: $includeAssignments,
includeScopeTags: $this->includeScopeTags, includeScopeTags: $includeScopeTags,
createdBy: $run->user?->email ? Str::limit($run->user->email, 255, '') : null, createdBy: $initiator?->email ? Str::limit((string) $initiator->email, 255, '') : null,
metadata: [ metadata: [
'source' => 'backup', 'source' => 'backup',
'backup_set_id' => $backupSet->getKey(), 'backup_set_id' => $backupSet->getKey(),
], ],
); );
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
$reason = $bulkOperationService->sanitizeFailureReason($throwable->getMessage()); $reason = RunFailureSanitizer::sanitizeMessage($throwable->getMessage());
$newBackupFailures[] = [ $newBackupFailures[] = [
'policy_id' => $policyId, 'policy_id' => $policyId,
@ -253,12 +269,15 @@ public function handle(
]; ];
$didMutateBackupSet = true; $didMutateBackupSet = true;
$bulkOperationService->recordFailure( $operationRunService->incrementSummaryCounts($this->operationRun, [
run: $run, 'processed' => 1,
itemId: (string) $policyId, 'failed' => 1,
reason: $reason, ]);
reasonCode: 'unknown',
); $runFailuresForOperationRun[] = [
'code' => 'policy.capture_exception',
'message' => $reason,
];
continue; continue;
} }
@ -267,7 +286,7 @@ public function handle(
$failure = $captureResult['failure']; $failure = $captureResult['failure'];
$status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null; $status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null;
$reasonCode = $this->mapGraphFailureReasonCode($status); $reasonCode = $this->mapGraphFailureReasonCode($status);
$reason = $bulkOperationService->sanitizeFailureReason((string) ($failure['reason'] ?? 'Graph capture failed.')); $reason = RunFailureSanitizer::sanitizeMessage((string) ($failure['reason'] ?? 'Graph capture failed.'));
$newBackupFailures[] = [ $newBackupFailures[] = [
'policy_id' => $policyId, 'policy_id' => $policyId,
@ -277,12 +296,15 @@ public function handle(
]; ];
$didMutateBackupSet = true; $didMutateBackupSet = true;
$bulkOperationService->recordFailure( $operationRunService->incrementSummaryCounts($this->operationRun, [
run: $run, 'processed' => 1,
itemId: (string) $policyId, 'failed' => 1,
reason: $reason, ]);
reasonCode: $reasonCode,
); $runFailuresForOperationRun[] = [
'code' => "graph.{$reasonCode}",
'message' => $reason,
];
continue; continue;
} }
@ -293,18 +315,21 @@ public function handle(
if (! $version || ! is_array($captured)) { if (! $version || ! is_array($captured)) {
$newBackupFailures[] = [ $newBackupFailures[] = [
'policy_id' => $policyId, 'policy_id' => $policyId,
'reason' => $bulkOperationService->sanitizeFailureReason('Capture result missing version payload.'), 'reason' => RunFailureSanitizer::sanitizeMessage('Capture result missing version payload.'),
'status' => null, 'status' => null,
'reason_code' => 'unknown', 'reason_code' => 'unknown',
]; ];
$didMutateBackupSet = true; $didMutateBackupSet = true;
$bulkOperationService->recordFailure( $operationRunService->incrementSummaryCounts($this->operationRun, [
run: $run, 'processed' => 1,
itemId: (string) $policyId, 'failed' => 1,
reason: 'Capture result missing version payload.', ]);
reasonCode: 'unknown',
); $runFailuresForOperationRun[] = [
'code' => 'capture.missing_payload',
'message' => 'Capture result missing version payload.',
];
continue; continue;
} }
@ -352,12 +377,10 @@ public function handle(
]); ]);
} catch (QueryException $exception) { } catch (QueryException $exception) {
if ((string) $exception->getCode() === '23505') { if ((string) $exception->getCode() === '23505') {
$bulkOperationService->recordSkippedWithReason( $operationRunService->incrementSummaryCounts($this->operationRun, [
run: $run, 'processed' => 1,
itemId: (string) $policyId, 'skipped' => 1,
reason: 'Already in backup set', ]);
reasonCode: 'already_in_backup_set',
);
continue; continue;
} }
@ -369,12 +392,15 @@ public function handle(
$didMutateBackupSet = true; $didMutateBackupSet = true;
$backupSetItemMutations++; $backupSetItemMutations++;
$bulkOperationService->recordSuccess($run); $operationRunService->incrementSummaryCounts($this->operationRun, [
'processed' => 1,
'succeeded' => 1,
'created' => 1,
]);
} }
if ($this->includeFoundations) { if ($includeFoundations) {
[$foundationOutcome, $foundationFailureEntries] = $this->captureFoundations( [$foundationOutcome, $foundationFailureEntries] = $this->captureFoundations(
bulkOperationService: $bulkOperationService,
foundationSnapshots: $foundationSnapshots, foundationSnapshots: $foundationSnapshots,
tenant: $tenant, tenant: $tenant,
backupSet: $backupSet, backupSet: $backupSet,
@ -391,13 +417,10 @@ public function handle(
$newBackupFailures = array_merge($newBackupFailures, $foundationFailureEntries); $newBackupFailures = array_merge($newBackupFailures, $foundationFailureEntries);
foreach ($foundationFailureEntries as $foundationFailure) { foreach ($foundationFailureEntries as $foundationFailure) {
$this->appendRunFailure($run, [ $runFailuresForOperationRun[] = [
'type' => 'foundation', 'code' => 'foundation.capture_failed',
'item_id' => (string) ($foundationFailure['foundation_type'] ?? 'foundation'), 'message' => (string) ($foundationFailure['reason'] ?? 'Foundation capture failed.'),
'reason_code' => (string) ($foundationFailure['reason_code'] ?? 'unknown'), ];
'reason' => (string) ($foundationFailure['reason'] ?? 'Foundation capture failed.'),
'status' => $foundationFailure['status'] ?? null,
]);
} }
} }
} }
@ -420,45 +443,41 @@ public function handle(
]); ]);
} }
$bulkOperationService->complete($run); $this->operationRun->refresh();
if ($this->operationRun) { $counts = is_array($this->operationRun->summary_counts) ? $this->operationRun->summary_counts : [];
/** @var OperationRunService $opService */ $failed = (int) ($counts['failed'] ?? 0);
$opService = app(OperationRunService::class); $succeeded = (int) ($counts['succeeded'] ?? 0);
$skipped = (int) ($counts['skipped'] ?? 0);
$opOutcome = match (true) { $outcome = 'succeeded';
$run->status === 'completed' => 'succeeded', if ($failed > 0 && $succeeded > 0) {
$run->status === 'completed_with_errors' => 'partially_succeeded', $outcome = 'partially_succeeded';
$run->status === 'failed' => 'failed', }
default => 'failed' if ($failed > 0 && $succeeded === 0) {
}; $outcome = 'failed';
$opService->updateRun(
$this->operationRun,
'completed',
$opOutcome,
[
'policies_added' => $backupSetItemMutations,
'foundations_added' => $foundationMutations,
'failures' => count($newBackupFailures),
],
$newBackupFailures
);
} }
if (! $run->user) { $operationRunService->updateRun(
$this->operationRun,
status: 'completed',
outcome: $outcome,
failures: $runFailuresForOperationRun,
);
if (! $initiator instanceof User) {
return; return;
} }
$message = "Added {$run->succeeded} policies"; $message = "Added {$succeeded} policies";
if ($run->skipped > 0) { if ($skipped > 0) {
$message .= " ({$run->skipped} skipped)"; $message .= " ({$skipped} skipped)";
} }
if ($run->failed > 0) { if ($failed > 0) {
$message .= " ({$run->failed} failed)"; $message .= " ({$failed} failed)";
} }
if ($this->includeFoundations) { if ($includeFoundations) {
$message .= ". Foundations: {$foundationMutations} items"; $message .= ". Foundations: {$foundationMutations} items";
if ($foundationFailures > 0) { if ($foundationFailures > 0) {
@ -468,7 +487,7 @@ public function handle(
$message .= '.'; $message .= '.';
$partial = $run->status === 'completed_with_errors' || $foundationFailures > 0; $partial = $outcome === 'partially_succeeded' || $foundationFailures > 0;
$notification = Notification::make() $notification = Notification::make()
->title($partial ? 'Add Policies Completed (partial)' : 'Add Policies Completed') ->title($partial ? 'Add Policies Completed (partial)' : 'Add Policies Completed')
@ -476,7 +495,7 @@ public function handle(
->actions([ ->actions([
\Filament\Actions\Action::make('view_run') \Filament\Actions\Action::make('view_run')
->label('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) { if ($partial) {
@ -486,22 +505,15 @@ public function handle(
} }
$notification $notification
->sendToDatabase($run->user) ->sendToDatabase($initiator)
->send(); ->send();
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
$run->refresh(); $this->failRun(
operationRunService: $operationRunService,
if (in_array($run->status, ['completed', 'completed_with_errors'], true)) {
throw $throwable;
}
$this->markRunFailed(
bulkOperationService: $bulkOperationService,
run: $run,
tenant: $tenant instanceof Tenant ? $tenant : null, tenant: $tenant instanceof Tenant ? $tenant : null,
itemId: (string) $this->backupSetId, code: 'exception.unhandled',
reasonCode: 'unknown', message: $throwable->getMessage(),
reason: $throwable->getMessage(), initiator: $initiator,
); );
// TrackOperationRun will catch this throw // TrackOperationRun will catch this throw
@ -510,20 +522,11 @@ public function handle(
} }
/** /**
* @param array<int, int> $policyIds
* @return array<int> * @return array<int>
*/ */
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_unique(array_map('intval', $policyIds)));
$policyIds = array_values(array_filter($policyIds, fn (int $value): bool => $value > 0)); $policyIds = array_values(array_filter($policyIds, fn (int $value): bool => $value > 0));
sort($policyIds); sort($policyIds);
@ -531,61 +534,32 @@ private function extractPolicyIds(BulkOperationRun $run): array
return $policyIds; return $policyIds;
} }
/** private function failRun(
* @param array<string, mixed> $entry OperationRunService $operationRunService,
*/
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,
?Tenant $tenant, ?Tenant $tenant,
string $itemId, string $code,
string $reasonCode, string $message,
string $reason, ?User $initiator = null,
): void { ): void {
$reason = $bulkOperationService->sanitizeFailureReason($reason); $safeMessage = RunFailureSanitizer::sanitizeMessage($message);
$safeCode = RunFailureSanitizer::sanitizeCode($code);
$this->appendRunFailure($run, [ $operationRunService->updateRun(
'type' => 'run', $this->operationRun,
'item_id' => $itemId, status: 'completed',
'reason_code' => $reasonCode, outcome: 'failed',
'reason' => $reason, failures: [[
]); 'code' => $safeCode,
'message' => $safeMessage,
]],
);
try { $this->notifyRunFailed($initiator, $tenant, $safeMessage);
$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);
} }
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; return;
} }
@ -597,13 +571,13 @@ private function notifyRunFailed(BulkOperationRun $run, ?Tenant $tenant, string
$notification->actions([ $notification->actions([
\Filament\Actions\Action::make('view_run') \Filament\Actions\Action::make('view_run')
->label('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 $notification
->danger() ->danger()
->sendToDatabase($run->user) ->sendToDatabase($initiator)
->send(); ->send();
} }
@ -621,7 +595,6 @@ private function mapGraphFailureReasonCode(?int $status): string
* @return array{0:array{created:int,restored:int,failures:array<int,array{foundation_type:string,reason:string,status:int|string|null,reason_code:string}>},1:array<int,array{foundation_type:string,reason:string,status:int|string|null,reason_code:string}>} * @return array{0:array{created:int,restored:int,failures:array<int,array{foundation_type:string,reason:string,status:int|string|null,reason_code:string}>},1:array<int,array{foundation_type:string,reason:string,status:int|string|null,reason_code:string}>}
*/ */
private function captureFoundations( private function captureFoundations(
BulkOperationService $bulkOperationService,
FoundationSnapshotService $foundationSnapshots, FoundationSnapshotService $foundationSnapshots,
Tenant $tenant, Tenant $tenant,
BackupSet $backupSet, BackupSet $backupSet,
@ -647,7 +620,7 @@ private function captureFoundations(
$status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null; $status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null;
$reasonCode = $this->mapGraphFailureReasonCode($status); $reasonCode = $this->mapGraphFailureReasonCode($status);
$reason = $bulkOperationService->sanitizeFailureReason((string) ($failure['reason'] ?? 'Foundation capture failed.')); $reason = RunFailureSanitizer::sanitizeMessage((string) ($failure['reason'] ?? 'Foundation capture failed.'));
$failures[] = [ $failures[] = [
'foundation_type' => $foundationType, 'foundation_type' => $foundationType,

View File

@ -2,149 +2,93 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\BackupSet; use App\Jobs\Operations\BackupSetDeleteWorkerJob;
use App\Models\BulkOperationRun; use App\Models\OperationRun;
use App\Services\BulkOperationService; use App\Services\OperationRunService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Throwable; use RuntimeException;
class BulkBackupSetDeleteJob implements ShouldQueue class BulkBackupSetDeleteJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $backupSetIds
* @param array<string, mixed> $context
*/
public function __construct( 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; return;
} }
$service->start($run); $runs->updateRun($this->operationRun, 'running');
$itemCount = 0; $ids = $this->normalizeIds($this->backupSetIds);
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
foreach (($run->item_ids ?? []) as $backupSetId) { $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$itemCount++; $chunkSize = max(1, $chunkSize);
try { foreach (array_chunk($ids, $chunkSize) as $chunk) {
/** @var BackupSet|null $backupSet */ foreach ($chunk as $backupSetId) {
$backupSet = BackupSet::withTrashed() dispatch(new BackupSetDeleteWorkerJob(
->where('tenant_id', $run->tenant_id) tenantId: $this->tenantId,
->whereKey($backupSetId) userId: $this->userId,
->first(); backupSetId: $backupSetId,
operationRun: $this->operationRun,
context: $this->context,
));
}
}
}
if (! $backupSet) { /**
$service->recordFailure($run, (string) $backupSetId, 'Backup set not found'); * @param array<int, mixed> $ids
$failed++; * @return array<int, int>
*/
private function normalizeIds(array $ids): array
{
$normalized = [];
if ($failed > $failureThreshold) { foreach ($ids as $id) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.'); if (is_int($id)) {
$normalized[] = $id;
if ($run->user) { continue;
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;
}
} }
if ($itemCount % $chunkSize === 0) { if (is_numeric($id)) {
$run->refresh(); $normalized[] = (int) $id;
} }
} }
$service->complete($run); $normalized = array_values(array_unique($normalized));
sort($normalized);
if (! $run->user) { return $normalized;
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();
} }
} }

View File

@ -2,159 +2,93 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\BackupSet; use App\Jobs\Operations\BackupSetForceDeleteWorkerJob;
use App\Models\BulkOperationRun; use App\Models\OperationRun;
use App\Services\BulkOperationService; use App\Services\OperationRunService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Throwable; use RuntimeException;
class BulkBackupSetForceDeleteJob implements ShouldQueue class BulkBackupSetForceDeleteJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $backupSetIds
* @param array<string, mixed> $context
*/
public function __construct( 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; return;
} }
$service->start($run); $runs->updateRun($this->operationRun, 'running');
$itemCount = 0; $ids = $this->normalizeIds($this->backupSetIds);
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
foreach (($run->item_ids ?? []) as $backupSetId) { $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$itemCount++; $chunkSize = max(1, $chunkSize);
try { foreach (array_chunk($ids, $chunkSize) as $chunk) {
/** @var BackupSet|null $backupSet */ foreach ($chunk as $backupSetId) {
$backupSet = BackupSet::withTrashed() dispatch(new BackupSetForceDeleteWorkerJob(
->where('tenant_id', $run->tenant_id) tenantId: $this->tenantId,
->whereKey($backupSetId) userId: $this->userId,
->first(); backupSetId: $backupSetId,
operationRun: $this->operationRun,
context: $this->context,
));
}
}
}
if (! $backupSet) { /**
$service->recordFailure($run, (string) $backupSetId, 'Backup set not found'); * @param array<int, mixed> $ids
$failed++; * @return array<int, int>
*/
private function normalizeIds(array $ids): array
{
$normalized = [];
if ($failed > $failureThreshold) { foreach ($ids as $id) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.'); if (is_int($id)) {
$normalized[] = $id;
if ($run->user) { continue;
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;
}
} }
if ($itemCount % $chunkSize === 0) { if (is_numeric($id)) {
$run->refresh(); $normalized[] = (int) $id;
} }
} }
$service->complete($run); $normalized = array_values(array_unique($normalized));
sort($normalized);
if (! $run->user) { return $normalized;
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();
} }
} }

View File

@ -2,151 +2,138 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\BackupSet; use App\Jobs\Operations\BackupSetRestoreWorkerJob;
use App\Models\BulkOperationRun; use App\Models\OperationRun;
use App\Services\BulkOperationService; use App\Services\OperationRunService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use RuntimeException;
use Throwable; use Throwable;
class BulkBackupSetRestoreJob implements ShouldQueue class BulkBackupSetRestoreJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $bulkRunId = 0;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $backupSetIds
* @param array<string, mixed> $context
*/
public function __construct( 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; return;
} }
$service->start($run); $runs->updateRun($this->operationRun, 'running');
$itemCount = 0; $ids = $this->normalizeIds($this->backupSetIds);
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
foreach (($run->item_ids ?? []) as $backupSetId) { $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$itemCount++; $chunkSize = max(1, $chunkSize);
try { foreach (array_chunk($ids, $chunkSize) as $chunk) {
/** @var BackupSet|null $backupSet */ foreach ($chunk as $backupSetId) {
$backupSet = BackupSet::withTrashed() dispatch(new BackupSetRestoreWorkerJob(
->where('tenant_id', $run->tenant_id) tenantId: $this->tenantId,
->whereKey($backupSetId) userId: $this->userId,
->first(); backupSetId: $backupSetId,
operationRun: $this->operationRun,
if (! $backupSet) { context: $this->context,
$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();
} }
} }
}
$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; return;
} }
$message = "Restored {$succeeded} backup sets"; /** @var OperationRunService $runs */
if ($skipped > 0) { $runs = app(OperationRunService::class);
$message .= " ({$skipped} skipped)";
} $runs->updateRun(
if ($failed > 0) { $run,
$message .= " ({$failed} failed)"; 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)) { if ($this->bulkRunId > 0) {
$summary = collect($skipReasons) $run = OperationRun::query()->find($this->bulkRunId);
->sortDesc()
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
->take(3)
->implode(', ');
if ($summary !== '') { if ($run instanceof OperationRun) {
$message .= " Skip reasons: {$summary}."; return $run;
} }
} }
$message .= '.'; throw new RuntimeException('OperationRun is required for BulkBackupSetRestoreJob.');
}
Notification::make() /**
->title('Bulk Restore Completed') * @param array<int, mixed> $ids
->body($message) * @return array<int, int>
->icon('heroicon-o-check-circle') */
->success() private function normalizeIds(array $ids): array
->sendToDatabase($run->user) {
->send(); $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;
} }
} }

View File

@ -2,184 +2,93 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\BulkOperationRun; use App\Jobs\Operations\PolicyBulkDeleteWorkerJob;
use App\Models\Policy; use App\Models\OperationRun;
use App\Services\BulkOperationService; use App\Services\OperationRunService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Throwable; use RuntimeException;
class BulkPolicyDeleteJob implements ShouldQueue class BulkPolicyDeleteJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $policyIds
* @param array<string, mixed> $context
*/
public function __construct( 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(OperationRunService $runs): void
) {}
public function handle(BulkOperationService $service): void
{ {
if (! $this->operationRun instanceof OperationRun) {
throw new RuntimeException('OperationRun is required for BulkPolicyDeleteJob.');
}
$run = BulkOperationRun::with('user')->find($this->bulkRunId); $this->operationRun->refresh();
if (! $run || $run->status !== 'pending') {
if ($this->operationRun->status === 'completed') {
return; return;
} }
$service->start($run); $runs->updateRun($this->operationRun, 'running');
try { $ids = $this->normalizeIds($this->policyIds);
$itemCount = 0; $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
$succeeded = 0;
$failed = 0;
$skipped = 0;
$failures = [];
$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 ?? []); foreach (array_chunk($ids, $chunkSize) as $chunk) {
$failureThreshold = (int) floor($totalItems / 2); 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<int, mixed> $ids
* @return array<int, int>
*/
private function normalizeIds(array $ids): array
{
$normalized = [];
$itemCount++; foreach ($ids as $id) {
if (is_int($id)) {
try { $normalized[] = $id;
$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();
}
continue;
} }
$service->complete($run); if (is_numeric($id)) {
$normalized[] = (int) $id;
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();
} }
} 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;
} }
} }

View File

@ -2,11 +2,17 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\BulkOperationRun; use App\Models\OperationRun;
use App\Models\Policy; 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 Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -19,31 +25,53 @@ class BulkPolicyExportJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct( public function __construct(
public int $bulkRunId, public int $tenantId,
public int $userId,
/** @var array<int, int|string> */
public array $policyIds,
public string $backupName, 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') { public function handle(OperationRunService $operationRunService): void
return; {
$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 { try {
// Create Backup Set // Create Backup Set
$backupSet = BackupSet::create([ $backupSet = BackupSet::create([
'tenant_id' => $run->tenant_id, 'tenant_id' => $tenant->getKey(),
'name' => $this->backupName, 'name' => $this->backupName,
// 'description' => $this->backupDescription, // Not in schema // 'description' => $this->backupDescription, // Not in schema
'status' => 'completed', 'status' => 'completed',
'created_by' => $run->user?->name ?? (string) $run->user_id, // Schema has created_by string 'created_by' => $user->name,
'item_count' => count($run->item_ids), 'item_count' => count($ids),
'completed_at' => now(), 'completed_at' => now(),
]); ]);
@ -51,37 +79,55 @@ public function handle(BulkOperationService $service): void
$succeeded = 0; $succeeded = 0;
$failed = 0; $failed = 0;
$failures = []; $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); $failureThreshold = (int) floor($totalItems / 2);
foreach ($run->item_ids as $policyId) { foreach ($ids as $policyId) {
$itemCount++; $itemCount++;
try { try {
$policy = Policy::find($policyId); $policy = Policy::query()
->where('tenant_id', $tenant->getKey())
->find($policyId);
if (! $policy) { if (! $policy) {
$service->recordFailure($run, (string) $policyId, 'Policy not found');
$failed++; $failed++;
$failures[] = [ $failures[] = ['code' => 'policy.not_found', 'message' => "Policy {$policyId} not found."];
'item_id' => (string) $policyId,
'reason' => 'Policy not found',
'timestamp' => now()->toIso8601String(),
];
if ($failed > $failureThreshold) { if ($failed > $failureThreshold) {
$backupSet->update(['status' => 'failed']); $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() Notification::make()
->title('Bulk Export Aborted') ->title('Bulk Export Aborted')
->body('Circuit breaker triggered: too many failures (>50%).') ->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle') ->icon('heroicon-o-exclamation-triangle')
->danger() ->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(); ->send();
} }
@ -95,25 +141,42 @@ public function handle(BulkOperationService $service): void
$latestVersion = $policy->versions()->orderByDesc('captured_at')->first(); $latestVersion = $policy->versions()->orderByDesc('captured_at')->first();
if (! $latestVersion) { if (! $latestVersion) {
$service->recordFailure($run, (string) $policyId, 'No versions available for policy');
$failed++; $failed++;
$failures[] = [ $failures[] = ['code' => 'policy.no_versions', 'message' => "No versions available for policy {$policyId}."];
'item_id' => (string) $policyId,
'reason' => 'No versions available for policy',
'timestamp' => now()->toIso8601String(),
];
if ($failed > $failureThreshold) { if ($failed > $failureThreshold) {
$backupSet->update(['status' => 'failed']); $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() Notification::make()
->title('Bulk Export Aborted') ->title('Bulk Export Aborted')
->body('Circuit breaker triggered: too many failures (>50%).') ->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle') ->icon('heroicon-o-exclamation-triangle')
->danger() ->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(); ->send();
} }
@ -125,7 +188,7 @@ public function handle(BulkOperationService $service): void
// Create Backup Item // Create Backup Item
BackupItem::create([ BackupItem::create([
'tenant_id' => $run->tenant_id, 'tenant_id' => $tenant->getKey(),
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id, 'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id, // Added 'policy_identifier' => $policy->external_id, // Added
@ -139,46 +202,81 @@ public function handle(BulkOperationService $service): void
], ],
]); ]);
$service->recordSuccess($run);
$succeeded++; $succeeded++;
} catch (Throwable $e) { } catch (Throwable $e) {
$service->recordFailure($run, (string) $policyId, $e->getMessage());
$failed++; $failed++;
$failures[] = [ $failures[] = ['code' => 'policy.export.failed', 'message' => $e->getMessage()];
'item_id' => (string) $policyId,
'reason' => $e->getMessage(),
'timestamp' => now()->toIso8601String(),
];
if ($failed > $failureThreshold) { if ($failed > $failureThreshold) {
$backupSet->update(['status' => 'failed']); $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() Notification::make()
->title('Bulk Export Aborted') ->title('Bulk Export Aborted')
->body('Circuit breaker triggered: too many failures (>50%).') ->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle') ->icon('heroicon-o-exclamation-triangle')
->danger() ->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(); ->send();
} }
return; 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 // Update BackupSet item count (if denormalized) or just leave it
// Assuming BackupSet might need an item count or status update // 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) { if ($succeeded > 0 || $failed > 0) {
$message = "Successfully exported {$succeeded} policies to backup '{$this->backupName}'"; $message = "Successfully exported {$succeeded} policies to backup '{$this->backupName}'";
@ -192,24 +290,39 @@ public function handle(BulkOperationService $service): void
->body($message) ->body($message)
->icon('heroicon-o-check-circle') ->icon('heroicon-o-check-circle')
->success() ->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(); ->send();
} }
} catch (Throwable $e) { } 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 if (isset($user) && $user instanceof User) {
$run->refresh();
$run->load('user');
if ($run->user) {
Notification::make() Notification::make()
->title('Bulk Export Failed') ->title('Bulk Export Failed')
->body($e->getMessage()) ->body($e->getMessage())
->icon('heroicon-o-x-circle') ->icon('heroicon-o-x-circle')
->danger() ->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(); ->send();
} }

View File

@ -2,17 +2,14 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\BulkOperationRun; use App\Models\OperationRun;
use App\Models\Policy;
use App\Services\BulkOperationService;
use App\Services\Intune\PolicySyncService; use App\Services\Intune\PolicySyncService;
use Filament\Notifications\Notification; use App\Services\OperationRunService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Throwable;
class BulkPolicySyncJob implements ShouldQueue class BulkPolicySyncJob implements ShouldQueue
{ {
@ -20,128 +17,24 @@ class BulkPolicySyncJob implements ShouldQueue
public function __construct(public int $bulkRunId) {} 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; return;
} }
$service->start($run); (new SyncPoliciesJob(
tenantId: (int) $run->tenant_id,
try { types: null,
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); policyIds: $policyIds,
$itemCount = 0; operationRun: $run,
))->handle($syncService, $runs);
$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;
}
} }
} }

View File

@ -2,9 +2,15 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\BulkOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\Policy; 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 Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -17,63 +23,113 @@ class BulkPolicyUnignoreJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $bulkRunId) {} public ?OperationRun $operationRun = null;
public function handle(BulkOperationService $service): void /**
* @param array<int, int|string> $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') { public function handle(OperationRunService $operationRunService): void
return; {
$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 { try {
$itemCount = 0;
$succeeded = 0; $succeeded = 0;
$failed = 0; $failed = 0;
$skipped = 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) { foreach ($ids as $policyId) {
$itemCount++;
try { try {
$policy = Policy::find($policyId); $policy = Policy::query()
->where('tenant_id', $tenant->getKey())
->find($policyId);
if (! $policy) { if (! $policy) {
$service->recordFailure($run, (string) $policyId, 'Policy not found');
$failed++; $failed++;
$failures[] = [
'code' => 'policy.not_found',
'message' => "Policy {$policyId} not found.",
];
continue; continue;
} }
if (! $policy->ignored_at) { if (! $policy->ignored_at) {
$service->recordSkipped($run);
$skipped++; $skipped++;
continue; continue;
} }
$policy->unignore(); $policy->unignore();
$service->recordSuccess($run);
$succeeded++; $succeeded++;
} catch (Throwable $e) { } catch (Throwable $e) {
$service->recordFailure($run, (string) $policyId, $e->getMessage());
$failed++; $failed++;
} $failures[] = [
'code' => 'policy.unignore.failed',
if ($itemCount % $chunkSize === 0) { 'message' => $e->getMessage(),
$run->refresh(); ];
} }
} }
$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"; $message = "Restored {$succeeded} policies";
if ($skipped > 0) { if ($skipped > 0) {
@ -91,22 +147,38 @@ public function handle(BulkOperationService $service): void
->body($message) ->body($message)
->icon('heroicon-o-check-circle') ->icon('heroicon-o-check-circle')
->success() ->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(); ->send();
} }
} catch (Throwable $e) { } 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(); if (isset($user) && $user instanceof User) {
$run->load('user');
if ($run->user) {
Notification::make() Notification::make()
->title('Bulk Restore Failed') ->title('Bulk Restore Failed')
->body($e->getMessage()) ->body($e->getMessage())
->icon('heroicon-o-x-circle') ->icon('heroicon-o-x-circle')
->danger() ->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(); ->send();
} }

View File

@ -2,147 +2,93 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\BulkOperationRun; use App\Jobs\Operations\PolicyVersionForceDeleteWorkerJob;
use App\Models\PolicyVersion; use App\Models\OperationRun;
use App\Services\BulkOperationService; use App\Services\OperationRunService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Throwable; use RuntimeException;
class BulkPolicyVersionForceDeleteJob implements ShouldQueue class BulkPolicyVersionForceDeleteJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $policyVersionIds
* @param array<string, mixed> $context
*/
public function __construct( 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; return;
} }
$service->start($run); $runs->updateRun($this->operationRun, 'running');
$itemCount = 0; $ids = $this->normalizeIds($this->policyVersionIds);
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
foreach (($run->item_ids ?? []) as $versionId) { $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$itemCount++; $chunkSize = max(1, $chunkSize);
try { foreach (array_chunk($ids, $chunkSize) as $chunk) {
/** @var PolicyVersion|null $version */ foreach ($chunk as $policyVersionId) {
$version = PolicyVersion::withTrashed() dispatch(new PolicyVersionForceDeleteWorkerJob(
->where('tenant_id', $run->tenant_id) tenantId: $this->tenantId,
->whereKey($versionId) userId: $this->userId,
->first(); policyVersionId: $policyVersionId,
operationRun: $this->operationRun,
if (! $version) { context: $this->context,
$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;
}
} }
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<int, mixed> $ids
* @return array<int, int>
*/
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;
}
} }

View File

@ -2,177 +2,95 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\BulkOperationRun; use App\Jobs\Operations\PolicyVersionPruneWorkerJob;
use App\Models\PolicyVersion; use App\Models\OperationRun;
use App\Services\BulkOperationService; use App\Services\OperationRunService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Throwable; use RuntimeException;
class BulkPolicyVersionPruneJob implements ShouldQueue class BulkPolicyVersionPruneJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $policyVersionIds
* @param array<string, mixed> $context
*/
public function __construct( public function __construct(
public int $bulkRunId, public int $tenantId,
public int $userId,
public array $policyVersionIds,
public int $retentionDays = 90, 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; return;
} }
$service->start($run); $runs->updateRun($this->operationRun, 'running');
$itemCount = 0; $ids = $this->normalizeIds($this->policyVersionIds);
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
foreach (($run->item_ids ?? []) as $versionId) { $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$itemCount++; $chunkSize = max(1, $chunkSize);
try { foreach (array_chunk($ids, $chunkSize) as $chunk) {
/** @var PolicyVersion|null $version */ foreach ($chunk as $policyVersionId) {
$version = PolicyVersion::withTrashed() dispatch(new PolicyVersionPruneWorkerJob(
->where('tenant_id', $run->tenant_id) tenantId: $this->tenantId,
->whereKey($versionId) userId: $this->userId,
->first(); policyVersionId: $policyVersionId,
retentionDays: $this->retentionDays,
if (! $version) { operationRun: $this->operationRun,
$service->recordFailure($run, (string) $versionId, 'Policy version not found'); context: $this->context,
$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;
}
} }
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<int, mixed> $ids
* @return array<int, int>
*/
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;
}
} }

View File

@ -2,147 +2,93 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\BulkOperationRun; use App\Jobs\Operations\PolicyVersionRestoreWorkerJob;
use App\Models\PolicyVersion; use App\Models\OperationRun;
use App\Services\BulkOperationService; use App\Services\OperationRunService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Throwable; use RuntimeException;
class BulkPolicyVersionRestoreJob implements ShouldQueue class BulkPolicyVersionRestoreJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $policyVersionIds
* @param array<string, mixed> $context
*/
public function __construct( 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; return;
} }
$service->start($run); $runs->updateRun($this->operationRun, 'running');
$itemCount = 0; $ids = $this->normalizeIds($this->policyVersionIds);
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
foreach (($run->item_ids ?? []) as $versionId) { $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$itemCount++; $chunkSize = max(1, $chunkSize);
try { foreach (array_chunk($ids, $chunkSize) as $chunk) {
/** @var PolicyVersion|null $version */ foreach ($chunk as $policyVersionId) {
$version = PolicyVersion::withTrashed() dispatch(new PolicyVersionRestoreWorkerJob(
->where('tenant_id', $run->tenant_id) tenantId: $this->tenantId,
->whereKey($versionId) userId: $this->userId,
->first(); policyVersionId: $policyVersionId,
operationRun: $this->operationRun,
if (! $version) { context: $this->context,
$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;
}
} }
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<int, mixed> $ids
* @return array<int, int>
*/
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;
}
} }

View File

@ -2,176 +2,93 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\BulkOperationRun; use App\Jobs\Operations\RestoreRunDeleteWorkerJob;
use App\Models\RestoreRun; use App\Models\OperationRun;
use App\Services\BulkOperationService; use App\Services\OperationRunService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Throwable; use RuntimeException;
class BulkRestoreRunDeleteJob implements ShouldQueue class BulkRestoreRunDeleteJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $restoreRunIds
* @param array<string, mixed> $context
*/
public function __construct( 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; return;
} }
$service->start($run); $runs->updateRun($this->operationRun, 'running');
try { $ids = $this->normalizeIds($this->restoreRunIds);
$itemCount = 0;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
foreach (($run->item_ids ?? []) as $restoreRunId) { $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$itemCount++; $chunkSize = max(1, $chunkSize);
try { foreach (array_chunk($ids, $chunkSize) as $chunk) {
/** @var RestoreRun|null $restoreRun */ foreach ($chunk as $restoreRunId) {
$restoreRun = RestoreRun::withTrashed() dispatch(new RestoreRunDeleteWorkerJob(
->where('tenant_id', $run->tenant_id) tenantId: $this->tenantId,
->whereKey($restoreRunId) userId: $this->userId,
->first(); restoreRunId: $restoreRunId,
operationRun: $this->operationRun,
if (! $restoreRun) { context: $this->context,
$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();
}
} }
$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<int, mixed> $ids
* @return array<int, int>
*/
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;
}
} }

View File

@ -2,9 +2,15 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\BulkOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\RestoreRun; 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 Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -17,19 +23,41 @@ class BulkRestoreRunForceDeleteJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct( public function __construct(
public int $bulkRunId, public int $tenantId,
) {} public int $userId,
/** @var array<int, int|string> */
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') { public function handle(OperationRunService $operationRunService): void
return; {
$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; $itemCount = 0;
$succeeded = 0; $succeeded = 0;
@ -37,34 +65,57 @@ public function handle(BulkOperationService $service): void
$skipped = 0; $skipped = 0;
$skipReasons = []; $skipReasons = [];
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $failures = [];
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$totalItems = count($ids);
$failureThreshold = (int) floor($totalItems / 2); $failureThreshold = (int) floor($totalItems / 2);
foreach (($run->item_ids ?? []) as $restoreRunId) { foreach ($ids as $restoreRunId) {
$itemCount++; $itemCount++;
try { try {
/** @var RestoreRun|null $restoreRun */ /** @var RestoreRun|null $restoreRun */
$restoreRun = RestoreRun::withTrashed() $restoreRun = RestoreRun::withTrashed()
->where('tenant_id', $run->tenant_id) ->where('tenant_id', $tenant->getKey())
->whereKey($restoreRunId) ->whereKey($restoreRunId)
->first(); ->first();
if (! $restoreRun) { if (! $restoreRun) {
$service->recordFailure($run, (string) $restoreRunId, 'Restore run not found');
$failed++; $failed++;
$failures[] = ['code' => 'restore_run.not_found', 'message' => "Restore run {$restoreRunId} not found."];
if ($failed > $failureThreshold) { 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() Notification::make()
->title('Bulk Force Delete Aborted') ->title('Bulk Force Delete Aborted')
->body('Circuit breaker triggered: too many failures (>50%).') ->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle') ->icon('heroicon-o-exclamation-triangle')
->danger() ->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(); ->send();
} }
@ -75,7 +126,6 @@ public function handle(BulkOperationService $service): void
} }
if (! $restoreRun->trashed()) { if (! $restoreRun->trashed()) {
$service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Not archived');
$skipped++; $skipped++;
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
@ -83,38 +133,76 @@ public function handle(BulkOperationService $service): void
} }
$restoreRun->forceDelete(); $restoreRun->forceDelete();
$service->recordSuccess($run);
$succeeded++; $succeeded++;
} catch (Throwable $e) { } catch (Throwable $e) {
$service->recordFailure($run, (string) $restoreRunId, $e->getMessage());
$failed++; $failed++;
$failures[] = ['code' => 'restore_run.force_delete.failed', 'message' => $e->getMessage()];
if ($failed > $failureThreshold) { 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() Notification::make()
->title('Bulk Force Delete Aborted') ->title('Bulk Force Delete Aborted')
->body('Circuit breaker triggered: too many failures (>50%).') ->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle') ->icon('heroicon-o-exclamation-triangle')
->danger() ->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(); ->send();
} }
return; return;
} }
} }
if ($itemCount % $chunkSize === 0) {
$run->refresh();
}
} }
$service->complete($run); $outcome = OperationRunOutcome::Succeeded->value;
if (! $run->user) { if ($failed > 0 && $failed < $totalItems) {
return; $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"; $message = "Force deleted {$succeeded} restore runs";
@ -144,7 +232,12 @@ public function handle(BulkOperationService $service): void
->body($message) ->body($message)
->icon('heroicon-o-check-circle') ->icon('heroicon-o-check-circle')
->success() ->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(); ->send();
} }
} }

View File

@ -2,9 +2,15 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\BulkOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\RestoreRun; 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 Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -17,19 +23,41 @@ class BulkRestoreRunRestoreJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct( public function __construct(
public int $bulkRunId, public int $tenantId,
) {} public int $userId,
/** @var array<int, int|string> */
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') { public function handle(OperationRunService $operationRunService): void
return; {
$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; $itemCount = 0;
$succeeded = 0; $succeeded = 0;
@ -37,34 +65,56 @@ public function handle(BulkOperationService $service): void
$skipped = 0; $skipped = 0;
$skipReasons = []; $skipReasons = [];
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $failures = [];
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$totalItems = count($ids);
$failureThreshold = (int) floor($totalItems / 2); $failureThreshold = (int) floor($totalItems / 2);
foreach (($run->item_ids ?? []) as $restoreRunId) { foreach ($ids as $restoreRunId) {
$itemCount++; $itemCount++;
try { try {
/** @var RestoreRun|null $restoreRun */ /** @var RestoreRun|null $restoreRun */
$restoreRun = RestoreRun::withTrashed() $restoreRun = RestoreRun::withTrashed()
->where('tenant_id', $run->tenant_id) ->where('tenant_id', $tenant->getKey())
->whereKey($restoreRunId) ->whereKey($restoreRunId)
->first(); ->first();
if (! $restoreRun) { if (! $restoreRun) {
$service->recordFailure($run, (string) $restoreRunId, 'Restore run not found');
$failed++; $failed++;
$failures[] = ['code' => 'restore_run.not_found', 'message' => "Restore run {$restoreRunId} not found."];
if ($failed > $failureThreshold) { 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() Notification::make()
->title('Bulk Restore Aborted') ->title('Bulk Restore Aborted')
->body('Circuit breaker triggered: too many failures (>50%).') ->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle') ->icon('heroicon-o-exclamation-triangle')
->danger() ->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(); ->send();
} }
@ -75,7 +125,6 @@ public function handle(BulkOperationService $service): void
} }
if (! $restoreRun->trashed()) { if (! $restoreRun->trashed()) {
$service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Not archived');
$skipped++; $skipped++;
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
@ -83,38 +132,74 @@ public function handle(BulkOperationService $service): void
} }
$restoreRun->restore(); $restoreRun->restore();
$service->recordSuccess($run);
$succeeded++; $succeeded++;
} catch (Throwable $e) { } catch (Throwable $e) {
$service->recordFailure($run, (string) $restoreRunId, $e->getMessage());
$failed++; $failed++;
$failures[] = ['code' => 'restore_run.restore.failed', 'message' => $e->getMessage()];
if ($failed > $failureThreshold) { 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() Notification::make()
->title('Bulk Restore Aborted') ->title('Bulk Restore Aborted')
->body('Circuit breaker triggered: too many failures (>50%).') ->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle') ->icon('heroicon-o-exclamation-triangle')
->danger() ->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(); ->send();
} }
return; return;
} }
} }
if ($itemCount % $chunkSize === 0) {
$run->refresh();
}
} }
$service->complete($run); $outcome = OperationRunOutcome::Succeeded->value;
if (! $run->user) { if ($failed > 0 && $failed < $totalItems) {
return; $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"; $message = "Restored {$succeeded} restore runs";
@ -144,7 +229,12 @@ public function handle(BulkOperationService $service): void
->body($message) ->body($message)
->icon('heroicon-o-check-circle') ->icon('heroicon-o-check-circle')
->success() ->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(); ->send();
} }
} }

View File

@ -2,151 +2,92 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\BulkOperationRun; use App\Jobs\Operations\TenantSyncWorkerJob;
use App\Models\Tenant; use App\Models\OperationRun;
use App\Services\BulkOperationService; use App\Services\OperationRunService;
use App\Services\Intune\PolicySyncService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Throwable; use RuntimeException;
class BulkTenantSyncJob implements ShouldQueue class BulkTenantSyncJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; 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<int, mixed> $tenantIds
* @param array<string, mixed> $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; return;
} }
$service->start($run); $runs->updateRun($this->operationRun, 'running');
try { $ids = $this->normalizeIds($this->tenantIds);
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$itemCount = 0;
$supported = config('tenantpilot.supported_policy_types'); $runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
$totalItems = $run->total_items ?: count($run->item_ids ?? []); $chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$failureThreshold = (int) floor($totalItems / 2); $chunkSize = max(1, $chunkSize);
foreach (($run->item_ids ?? []) as $tenantId) { foreach (array_chunk($ids, $chunkSize) as $chunk) {
$itemCount++; foreach ($chunk as $targetTenantId) {
dispatch(new TenantSyncWorkerJob(
try { tenantId: $targetTenantId,
$tenant = Tenant::query()->whereKey($tenantId)->first(); userId: $this->userId,
operationRun: $this->operationRun,
if (! $tenant) { context: $this->context,
$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();
}
} }
$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<int, mixed> $ids
* @return array<int, int>
*/
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;
}
} }

View File

@ -2,17 +2,15 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\BulkOperationRun; use App\Jobs\Operations\CapturePolicySnapshotWorkerJob;
use App\Models\Policy; use App\Models\OperationRun;
use App\Notifications\RunStatusChangedNotification; use App\Services\OperationRunService;
use App\Services\BulkOperationService;
use App\Services\Intune\VersionService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Throwable; use RuntimeException;
class CapturePolicySnapshotJob implements ShouldQueue class CapturePolicySnapshotJob implements ShouldQueue
{ {
@ -21,87 +19,48 @@ class CapturePolicySnapshotJob implements ShouldQueue
use Queueable; use Queueable;
use SerializesModels; use SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<string, mixed> $context
*/
public function __construct( public function __construct(
public int $bulkOperationRunId, public int $tenantId,
public int $userId,
public int $policyId, public int $policyId,
public bool $includeAssignments = true, public bool $includeAssignments = true,
public bool $includeScopeTags = true, public bool $includeScopeTags = true,
public ?string $createdBy = null, public ?string $createdBy = null,
) {} ?OperationRun $operationRun = null,
public array $context = [],
public function handle(BulkOperationService $bulkOperationService, VersionService $versionService): void ) {
{ $this->operationRun = $operationRun;
$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;
}
} }
private function notifyStatus(BulkOperationRun $run, string $status): void public function handle(OperationRunService $runs): void
{ {
if (! $run->relationLoaded('user')) { if (! $this->operationRun instanceof OperationRun) {
$run->loadMissing('user'); throw new RuntimeException('OperationRun is required for CapturePolicySnapshotJob.');
} }
if (! $run->user) { $this->operationRun->refresh();
if ($this->operationRun->status === 'completed') {
return; return;
} }
$normalizedStatus = $status === 'pending' ? 'queued' : $status; $runs->updateRun($this->operationRun, 'running');
$runs->incrementSummaryCounts($this->operationRun, ['total' => 1]);
$run->user->notify(new RunStatusChangedNotification([ dispatch(new CapturePolicySnapshotWorkerJob(
'tenant_id' => (int) $run->tenant_id, tenantId: $this->tenantId,
'run_type' => 'bulk_operation', userId: $this->userId,
'run_id' => (int) $run->getKey(), policyId: $this->policyId,
'status' => (string) $normalizedStatus, includeAssignments: $this->includeAssignments,
'counts' => [ includeScopeTags: $this->includeScopeTags,
'total' => (int) $run->total_items, createdBy: $this->createdBy,
'processed' => (int) $run->processed_items, operationRun: $this->operationRun,
'succeeded' => (int) $run->succeeded, context: $this->context,
'failed' => (int) $run->failed, ));
'skipped' => (int) $run->skipped,
],
]));
} }
} }

View File

@ -6,9 +6,9 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\User; use App\Models\User;
use App\Notifications\RunStatusChangedNotification; use App\Notifications\RunStatusChangedNotification;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\RestoreRunStatus; use App\Support\RestoreRunStatus;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
@ -28,7 +28,7 @@ public function __construct(
public ?string $actorName = null, 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); $restoreRun = RestoreRun::with(['tenant', 'backupSet'])->find($this->restoreRunId);
@ -122,7 +122,7 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger,
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
$restoreRun->refresh(); $restoreRun->refresh();
$safeReason = $bulkOperationService->sanitizeFailureReason($throwable->getMessage()); $safeReason = RunFailureSanitizer::sanitizeMessage($throwable->getMessage());
if ($restoreRun->status === RestoreRunStatus::Running->value) { if ($restoreRun->status === RestoreRunStatus::Running->value) {
$restoreRun->update([ $restoreRun->update([

View File

@ -2,17 +2,12 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BulkOperationRun;
use App\Models\InventorySyncRun; use App\Models\InventorySyncRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Drift\DriftFindingGenerator; use App\Services\Drift\DriftFindingGenerator;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunLinks; use App\Services\Operations\TargetScopeConcurrencyLimiter;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -28,62 +23,78 @@ class GenerateDriftFindingsJob implements ShouldQueue
public ?OperationRun $operationRun = null; public ?OperationRun $operationRun = null;
/**
* @param array<string, mixed> $context
*/
public function __construct( public function __construct(
public int $tenantId, public int $tenantId,
public int $userId, public int $userId,
public int $baselineRunId, public int $baselineRunId,
public int $currentRunId, public int $currentRunId,
public string $scopeKey, public string $scopeKey,
public int $bulkOperationRunId, ?OperationRun $operationRun = null,
?OperationRun $operationRun = null public array $context = [],
) { ) {
$this->operationRun = $operationRun; $this->operationRun = $operationRun;
} }
public function middleware(): array public function handle(
{ DriftFindingGenerator $generator,
return [new TrackOperationRun]; OperationRunService $runs,
} TargetScopeConcurrencyLimiter $limiter,
): void {
/**
* Execute the job.
*/
public function handle(DriftFindingGenerator $generator, BulkOperationService $bulkOperationService): void
{
Log::info('GenerateDriftFindingsJob: started', [ Log::info('GenerateDriftFindingsJob: started', [
'tenant_id' => $this->tenantId, 'tenant_id' => $this->tenantId,
'baseline_run_id' => $this->baselineRunId, 'baseline_run_id' => $this->baselineRunId,
'current_run_id' => $this->currentRunId, 'current_run_id' => $this->currentRunId,
'scope_key' => $this->scopeKey, 'scope_key' => $this->scopeKey,
'bulk_operation_run_id' => $this->bulkOperationRunId,
]); ]);
$tenant = Tenant::query()->find($this->tenantId); if (! $this->operationRun instanceof OperationRun) {
if (! $tenant instanceof Tenant) { throw new RuntimeException('OperationRun is required for drift generation.');
throw new RuntimeException('Tenant not found.');
} }
$baseline = InventorySyncRun::query()->find($this->baselineRunId); $this->operationRun->refresh();
if (! $baseline instanceof InventorySyncRun) {
throw new RuntimeException('Baseline run not found.'); if ($this->operationRun->status === 'completed') {
return;
} }
$current = InventorySyncRun::query()->find($this->currentRunId); $opContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
if (! $current instanceof InventorySyncRun) { $targetScope = is_array($opContext['target_scope'] ?? null) ? $opContext['target_scope'] : [];
throw new RuntimeException('Current run not found.');
$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 { 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( $created = $generator->generate(
tenant: $tenant, tenant: $tenant,
baseline: $baseline, baseline: $baseline,
@ -96,118 +107,40 @@ public function handle(DriftFindingGenerator $generator, BulkOperationService $b
'baseline_run_id' => $this->baselineRunId, 'baseline_run_id' => $this->baselineRunId,
'current_run_id' => $this->currentRunId, 'current_run_id' => $this->currentRunId,
'scope_key' => $this->scopeKey, 'scope_key' => $this->scopeKey,
'bulk_operation_run_id' => $this->bulkOperationRunId,
'created_findings_count' => $created, 'created_findings_count' => $created,
]); ]);
$bulkOperationService->recordSuccess($run); $runs->incrementSummaryCounts($this->operationRun, [
$bulkOperationService->complete($run); 'processed' => 1,
'succeeded' => 1,
'created' => $created,
]);
if ($this->operationRun) { $runs->maybeCompleteBulkRun($this->operationRun);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->updateRun(
$this->operationRun,
'completed',
'succeeded',
['findings_created' => $created]
);
}
$this->notifyStatus($run->refresh());
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error('GenerateDriftFindingsJob: failed', [ Log::error('GenerateDriftFindingsJob: failed', [
'tenant_id' => $this->tenantId, 'tenant_id' => $this->tenantId,
'baseline_run_id' => $this->baselineRunId, 'baseline_run_id' => $this->baselineRunId,
'current_run_id' => $this->currentRunId, 'current_run_id' => $this->currentRunId,
'scope_key' => $this->scopeKey, 'scope_key' => $this->scopeKey,
'bulk_operation_run_id' => $this->bulkOperationRunId,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
$bulkOperationService->recordFailure( $runs->incrementSummaryCounts($this->operationRun, [
run: $run, 'processed' => 1,
itemId: $this->scopeKey, 'failed' => 1,
reason: $e->getMessage(), ]);
reasonCode: 'unknown',
);
$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 $runs->maybeCompleteBulkRun($this->operationRun);
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->failRun($this->operationRun, $e);
}
$this->notifyStatus($run->refresh());
throw $e; throw $e;
} } finally {
} $lock->release();
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(),
]);
} }
} }
} }

View File

@ -0,0 +1,120 @@
<?php
namespace App\Jobs\Operations;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
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 BackupSetDeleteWorkerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<string, mixed> $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();
}
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace App\Jobs\Operations;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
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 BackupSetForceDeleteWorkerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<string, mixed> $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();
}
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace App\Jobs\Operations;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
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 BackupSetRestoreWorkerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<string, mixed> $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();
}
}
}

View File

@ -0,0 +1,101 @@
<?php
namespace App\Jobs\Operations;
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;
abstract class BulkOperationOrchestratorJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $itemIds
* @param array<string, mixed> $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<int, object>
*/
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<int, mixed> $itemIds
* @return array<int, string>
*/
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;
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Jobs\Operations;
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;
abstract class BulkOperationWorkerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* Create a new job instance.
*
* @param array<string, mixed> $context
*/
public function __construct(
public int $tenantId,
public int $userId,
public string $itemId,
?OperationRun $operationRun = null,
public array $context = [],
) {
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
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;
}

View File

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Operations;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Services\Intune\VersionService;
use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
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 CapturePolicySnapshotWorkerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<string, mixed> $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();
}
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace App\Jobs\Operations;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
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 PolicyBulkDeleteWorkerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<string, mixed> $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();
}
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace App\Jobs\Operations;
use App\Models\OperationRun;
use App\Models\PolicyVersion;
use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
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 PolicyVersionForceDeleteWorkerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<string, mixed> $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();
}
}
}

View File

@ -0,0 +1,138 @@
<?php
namespace App\Jobs\Operations;
use App\Models\OperationRun;
use App\Models\PolicyVersion;
use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
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 PolicyVersionPruneWorkerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<string, mixed> $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();
}
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace App\Jobs\Operations;
use App\Models\OperationRun;
use App\Models\PolicyVersion;
use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
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 PolicyVersionRestoreWorkerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<string, mixed> $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();
}
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace App\Jobs\Operations;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
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 RestoreRunDeleteWorkerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<string, mixed> $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();
}
}
}

View File

@ -0,0 +1,136 @@
<?php
namespace App\Jobs\Operations;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\PolicySyncService;
use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
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 TenantSyncWorkerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<string, mixed> $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();
}
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Jobs;
use App\Services\AdapterRunReconciler;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;
class ReconcileAdapterRunsJob implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*/
public function __construct()
{
//
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
/** @var AdapterRunReconciler $reconciler */
$reconciler = app(AdapterRunReconciler::class);
$result = $reconciler->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;
}
}
}

View File

@ -8,10 +8,10 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\RunFailureSanitizer;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -48,7 +48,6 @@ public function middleware(): array
public function handle( public function handle(
AuditLogger $auditLogger, AuditLogger $auditLogger,
BulkOperationService $bulkOperationService,
): void { ): void {
$backupSet = BackupSet::query()->with(['tenant'])->find($this->backupSetId); $backupSet = BackupSet::query()->with(['tenant'])->find($this->backupSetId);
@ -60,7 +59,7 @@ public function handle(
$this->operationRun, $this->operationRun,
'completed', 'completed',
'failed', 'failed',
['backup_set_id' => $this->backupSetId], ['failed' => 1],
[['code' => 'backup_set.not_found', 'message' => 'Backup set not found.']] [['code' => 'backup_set.not_found', 'message' => 'Backup set not found.']]
); );
} }
@ -97,7 +96,7 @@ public function handle(
foreach ($missingIds as $missingId) { foreach ($missingIds as $missingId) {
$failures[] = [ $failures[] = [
'code' => 'backup_item.not_found', '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 */ /** @var OperationRunService $opService */
$opService = app(OperationRunService::class); $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'; $outcome = 'succeeded';
if ($removed === 0) { if ($removed === 0) {
$outcome = 'failed'; $outcome = 'failed';
@ -160,11 +169,12 @@ public function handle(
'completed', 'completed',
$outcome, $outcome,
[ [
'backup_set_id' => (int) $backupSet->getKey(), 'total' => $requestedCount,
'requested' => $requestedCount, 'processed' => $requestedCount,
'removed' => $removed, 'succeeded' => $removed,
'missing' => count($missingIds), 'failed' => count($missingIds),
'remaining' => (int) $backupSet->item_count, 'deleted' => $removed,
'items' => $requestedCount,
], ],
$failures, $failures,
); );
@ -205,7 +215,7 @@ public function handle(
$this->notifyFailed( $this->notifyFailed(
initiator: $initiator, initiator: $initiator,
tenant: $tenant instanceof Tenant ? $tenant : null, tenant: $tenant instanceof Tenant ? $tenant : null,
reason: $bulkOperationService->sanitizeFailureReason($throwable->getMessage()), reason: RunFailureSanitizer::sanitizeMessage($throwable->getMessage()),
); );
throw $throwable; throw $throwable;

View File

@ -5,13 +5,11 @@
use App\Jobs\Middleware\TrackOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun; use App\Models\BackupScheduleRun;
use App\Models\BulkOperationRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\RunErrorMapper; use App\Services\BackupScheduling\RunErrorMapper;
use App\Services\BackupScheduling\ScheduleTimeService; use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Intune\BackupService; use App\Services\Intune\BackupService;
use App\Services\Intune\PolicySyncService; use App\Services\Intune\PolicySyncService;
@ -39,7 +37,6 @@ class RunBackupScheduleJob implements ShouldQueue
public function __construct( public function __construct(
public int $backupScheduleRunId, public int $backupScheduleRunId,
public ?int $bulkRunId = null,
?OperationRun $operationRun = null, ?OperationRun $operationRun = null,
) { ) {
$this->operationRun = $operationRun; $this->operationRun = $operationRun;
@ -57,7 +54,6 @@ public function handle(
ScheduleTimeService $scheduleTimeService, ScheduleTimeService $scheduleTimeService,
AuditLogger $auditLogger, AuditLogger $auditLogger,
RunErrorMapper $errorMapper, RunErrorMapper $errorMapper,
BulkOperationService $bulkOperationService,
): void { ): void {
$run = BackupScheduleRun::query() $run = BackupScheduleRun::query()
->with(['schedule', 'tenant', 'user']) ->with(['schedule', 'tenant', 'user'])
@ -67,9 +63,8 @@ public function handle(
if ($this->operationRun) { if ($this->operationRun) {
$this->markOperationRunFailed( $this->markOperationRunFailed(
run: $this->operationRun, run: $this->operationRun,
bulkOperationService: $bulkOperationService,
summaryCounts: [], summaryCounts: [],
reasonCode: 'RUN_NOT_FOUND', reasonCode: 'run_not_found',
reason: 'Backup schedule 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; $schedule = $run->schedule;
if (! $schedule instanceof BackupSchedule) { if (! $schedule instanceof BackupSchedule) {
@ -127,12 +107,12 @@ public function handle(
if ($this->operationRun) { if ($this->operationRun) {
$this->markOperationRunFailed( $this->markOperationRunFailed(
run: $this->operationRun, run: $this->operationRun,
bulkOperationService: $bulkOperationService,
summaryCounts: [ summaryCounts: [
'backup_schedule_id' => (int) $run->backup_schedule_id, 'total' => 0,
'backup_schedule_run_id' => (int) $run->getKey(), 'processed' => 0,
'failed' => 1,
], ],
reasonCode: 'SCHEDULE_NOT_FOUND', reasonCode: 'schedule_not_found',
reason: 'Schedule not found.', reason: 'Schedule not found.',
); );
} }
@ -151,12 +131,12 @@ public function handle(
if ($this->operationRun) { if ($this->operationRun) {
$this->markOperationRunFailed( $this->markOperationRunFailed(
run: $this->operationRun, run: $this->operationRun,
bulkOperationService: $bulkOperationService,
summaryCounts: [ summaryCounts: [
'backup_schedule_id' => (int) $run->backup_schedule_id, 'total' => 0,
'backup_schedule_run_id' => (int) $run->getKey(), 'processed' => 0,
'failed' => 1,
], ],
reasonCode: 'TENANT_NOT_FOUND', reasonCode: 'tenant_not_found',
reason: 'Tenant not found.', reason: 'Tenant not found.',
); );
} }
@ -175,14 +155,12 @@ public function handle(
errorMessage: 'Another run is already in progress for this schedule.', errorMessage: 'Another run is already in progress for this schedule.',
summary: ['reason' => 'concurrent_run'], summary: ['reason' => 'concurrent_run'],
scheduleTimeService: $scheduleTimeService, scheduleTimeService: $scheduleTimeService,
bulkRunId: $this->bulkRunId,
); );
$this->syncOperationRunFromRun( $this->syncOperationRunFromRun(
tenant: $tenant, tenant: $tenant,
schedule: $schedule, schedule: $schedule,
run: $run->refresh(), run: $run->refresh(),
bulkOperationService: $bulkOperationService,
); );
return; return;
@ -228,14 +206,12 @@ public function handle(
'unknown_policy_types' => $unknownTypes, 'unknown_policy_types' => $unknownTypes,
], ],
scheduleTimeService: $scheduleTimeService, scheduleTimeService: $scheduleTimeService,
bulkRunId: $this->bulkRunId,
); );
$this->syncOperationRunFromRun( $this->syncOperationRunFromRun(
tenant: $tenant, tenant: $tenant,
schedule: $schedule, schedule: $schedule,
run: $run->refresh(), run: $run->refresh(),
bulkOperationService: $bulkOperationService,
); );
return; return;
@ -294,14 +270,12 @@ public function handle(
summary: $summary, summary: $summary,
scheduleTimeService: $scheduleTimeService, scheduleTimeService: $scheduleTimeService,
backupSetId: (string) $backupSet->id, backupSetId: (string) $backupSet->id,
bulkRunId: $this->bulkRunId,
); );
$this->syncOperationRunFromRun( $this->syncOperationRunFromRun(
tenant: $tenant, tenant: $tenant,
schedule: $schedule, schedule: $schedule,
run: $run->refresh(), run: $run->refresh(),
bulkOperationService: $bulkOperationService,
); );
$auditLogger->log( $auditLogger->log(
@ -346,14 +320,12 @@ public function handle(
'attempt' => $attempt, 'attempt' => $attempt,
], ],
scheduleTimeService: $scheduleTimeService, scheduleTimeService: $scheduleTimeService,
bulkRunId: $this->bulkRunId,
); );
$this->syncOperationRunFromRun( $this->syncOperationRunFromRun(
tenant: $tenant, tenant: $tenant,
schedule: $schedule, schedule: $schedule,
run: $run->refresh(), run: $run->refresh(),
bulkOperationService: $bulkOperationService,
); );
$auditLogger->log( $auditLogger->log(
@ -438,7 +410,6 @@ private function syncOperationRunFromRun(
Tenant $tenant, Tenant $tenant,
BackupSchedule $schedule, BackupSchedule $schedule,
BackupScheduleRun $run, BackupScheduleRun $run,
BulkOperationService $bulkOperationService,
): void { ): void {
if (! $this->operationRun) { if (! $this->operationRun) {
return; return;
@ -457,23 +428,29 @@ private function syncOperationRunFromRun(
$summary = is_array($run->summary) ? $run->summary : []; $summary = is_array($run->summary) ? $run->summary : [];
$syncFailures = $summary['sync_failures'] ?? []; $syncFailures = $summary['sync_failures'] ?? [];
$summaryCounts = [ $policiesTotal = (int) ($summary['policies_total'] ?? 0);
'backup_schedule_id' => (int) $schedule->getKey(), $policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0);
'backup_schedule_run_id' => (int) $run->getKey(), $syncFailureCount = is_array($syncFailures) ? count($syncFailures) : 0;
'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,
];
$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 = []; $failures = [];
if (filled($run->error_message) || filled($run->error_code)) { if (filled($run->error_message) || filled($run->error_code)) {
$failures[] = [ $failures[] = [
'code' => (string) ($run->error_code ?: 'BACKUP_SCHEDULE_ERROR'), 'code' => strtolower((string) ($run->error_code ?: 'backup_schedule_error')),
'message' => $bulkOperationService->sanitizeFailureReason((string) ($run->error_message ?: 'Backup schedule run failed.')), 'message' => (string) ($run->error_message ?: 'Backup schedule run failed.'),
]; ];
} }
@ -501,8 +478,8 @@ private function syncOperationRunFromRun(
} }
$failures[] = [ $failures[] = [
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR', 'code' => $status !== null ? 'graph_http_'.(string) $status : 'graph_error',
'message' => $bulkOperationService->sanitizeFailureReason($message), 'message' => $message,
]; ];
} }
} }
@ -514,6 +491,7 @@ private function syncOperationRunFromRun(
'context' => array_merge($this->operationRun->context ?? [], [ 'context' => array_merge($this->operationRun->context ?? [], [
'backup_schedule_id' => (int) $schedule->getKey(), 'backup_schedule_id' => (int) $schedule->getKey(),
'backup_schedule_run_id' => (int) $run->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( private function markOperationRunFailed(
OperationRun $run, OperationRun $run,
BulkOperationService $bulkOperationService,
array $summaryCounts, array $summaryCounts,
string $reasonCode, string $reasonCode,
string $reason, string $reason,
@ -544,7 +521,7 @@ private function markOperationRunFailed(
failures: [ failures: [
[ [
'code' => $reasonCode, 'code' => $reasonCode,
'message' => $bulkOperationService->sanitizeFailureReason($reason), 'message' => $reason,
], ],
], ],
); );
@ -578,7 +555,6 @@ private function finishRun(
array $summary, array $summary,
ScheduleTimeService $scheduleTimeService, ScheduleTimeService $scheduleTimeService,
?string $backupSetId = null, ?string $backupSetId = null,
?int $bulkRunId = null,
): void { ): void {
$nowUtc = CarbonImmutable::now('UTC'); $nowUtc = CarbonImmutable::now('UTC');
@ -599,48 +575,6 @@ private function finishRun(
$this->notifyRunFinished($run, $schedule); $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)) { if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) {
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id)); Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
} }

View File

@ -3,16 +3,16 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BulkOperationRun;
use App\Models\InventorySyncRun; use App\Models\InventorySyncRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService; use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -33,7 +33,6 @@ class RunInventorySyncJob implements ShouldQueue
public function __construct( public function __construct(
public int $tenantId, public int $tenantId,
public int $userId, public int $userId,
public int $bulkRunId,
public int $inventorySyncRunId, public int $inventorySyncRunId,
?OperationRun $operationRun = null ?OperationRun $operationRun = null
) { ) {
@ -53,7 +52,7 @@ public function middleware(): array
/** /**
* Execute the job. * 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); $tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
@ -65,19 +64,19 @@ public function handle(BulkOperationService $bulkOperationService, InventorySync
throw new RuntimeException('User not found.'); 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); $run = InventorySyncRun::query()->find($this->inventorySyncRunId);
if (! $run instanceof InventorySyncRun) { if (! $run instanceof InventorySyncRun) {
throw new RuntimeException('InventorySyncRun not found.'); 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 = []; $processedPolicyTypes = [];
$successCount = 0;
$failedCount = 0;
// Note: The TrackOperationRun middleware will automatically set status to 'running' at start. // Note: The TrackOperationRun middleware will automatically set status to 'running' at start.
// It will also handle success completion if no exceptions thrown. // It will also handle success completion if no exceptions thrown.
@ -87,48 +86,36 @@ public function handle(BulkOperationService $bulkOperationService, InventorySync
$run = $inventorySyncService->executePendingRun( $run = $inventorySyncService->executePendingRun(
$run, $run,
$tenant, $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; $processedPolicyTypes[] = $policyType;
if ($success) { if ($success) {
$bulkOperationService->recordSuccess($bulkRun); $successCount++;
return; return;
} }
$bulkOperationService->recordFailure($bulkRun, $policyType, $errorCode ?? 'failed'); $failedCount++;
}, },
); );
$policyTypes = is_array($bulkRun->item_ids ?? null) ? $bulkRun->item_ids : []; if ($run->status === InventorySyncRun::STATUS_SUCCESS) {
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 ($this->operationRun) { if ($this->operationRun) {
/** @var OperationRunService $opService */ $operationRunService->updateRun(
$opService = app(OperationRunService::class);
$opService->updateRun(
$this->operationRun, $this->operationRun,
'completed', status: OperationRunStatus::Completed->value,
$outcome, outcome: OperationRunOutcome::Succeeded->value,
$counts, summaryCounts: [
$failures '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( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
@ -136,7 +123,6 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
context: [ context: [
'metadata' => [ 'metadata' => [
'inventory_sync_run_id' => $run->id, 'inventory_sync_run_id' => $run->id,
'bulk_run_id' => $bulkRun->id,
'selection_hash' => $run->selection_hash, 'selection_hash' => $run->selection_hash,
'observed' => $run->items_observed_count, 'observed' => $run->items_observed_count,
'upserted' => $run->items_upserted_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) { if ($run->status === InventorySyncRun::STATUS_PARTIAL) {
$bulkOperationService->complete($bulkRun); if ($this->operationRun) {
$operationRunService->updateRun(
$updateOpRun('partially_succeeded', [ $this->operationRun,
'observed' => $run->items_observed_count, status: OperationRunStatus::Completed->value,
'upserted' => $run->items_upserted_count, outcome: OperationRunOutcome::PartiallySucceeded->value,
'errors' => $run->errors_count, summaryCounts: [
], [ 'total' => count($policyTypes),
// Minimal error summary 'processed' => count($policyTypes),
['code' => 'PARTIAL_SYNC', 'message' => "Errors: {$run->errors_count}"], '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( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
@ -182,7 +176,6 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
context: [ context: [
'metadata' => [ 'metadata' => [
'inventory_sync_run_id' => $run->id, 'inventory_sync_run_id' => $run->id,
'bulk_run_id' => $bulkRun->id,
'selection_hash' => $run->selection_hash, 'selection_hash' => $run->selection_hash,
'observed' => $run->items_observed_count, 'observed' => $run->items_observed_count,
'upserted' => $run->items_upserted_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) { if ($run->status === InventorySyncRun::STATUS_SKIPPED) {
$reason = (string) (($run->error_codes ?? [])[0] ?? 'skipped'); $reason = (string) (($run->error_codes ?? [])[0] ?? 'skipped');
foreach ($policyTypes as $policyType) { if ($this->operationRun) {
$bulkOperationService->recordSkippedWithReason($bulkRun, (string) $policyType, $reason); $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( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
@ -228,7 +232,6 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
context: [ context: [
'metadata' => [ 'metadata' => [
'inventory_sync_run_id' => $run->id, 'inventory_sync_run_id' => $run->id,
'bulk_run_id' => $bulkRun->id,
'selection_hash' => $run->selection_hash, 'selection_hash' => $run->selection_hash,
'reason' => $reason, 'reason' => $reason,
], ],
@ -258,21 +261,30 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
$reason = (string) (($run->error_codes ?? [])[0] ?? 'failed'); $reason = (string) (($run->error_codes ?? [])[0] ?? 'failed');
$missingPolicyTypes = array_values(array_diff($policyTypes, array_unique($processedPolicyTypes))); $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( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'inventory.sync.failed', action: 'inventory.sync.failed',
context: [ context: [
'metadata' => [ 'metadata' => [
'inventory_sync_run_id' => $run->id, 'inventory_sync_run_id' => $run->id,
'bulk_run_id' => $bulkRun->id,
'selection_hash' => $run->selection_hash, 'selection_hash' => $run->selection_hash,
'reason' => $reason, 'reason' => $reason,
], ],

View File

@ -6,9 +6,10 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Intune\PolicySyncService; use App\Services\Intune\PolicySyncService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -39,7 +40,7 @@ public function middleware(): array
return [new TrackOperationRun]; return [new TrackOperationRun];
} }
public function handle(PolicySyncService $service, BulkOperationService $bulkOperationService): void public function handle(PolicySyncService $service, OperationRunService $operationRunService): void
{ {
$tenant = Tenant::findOrFail($this->tenantId); $tenant = Tenant::findOrFail($this->tenantId);
@ -63,7 +64,7 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe
if (! $policy) { if (! $policy) {
$failureSummary[] = [ $failureSummary[] = [
'code' => 'policy.not_found', 'code' => 'policy.not_found',
'message' => $bulkOperationService->sanitizeFailureReason("Policy {$policyId} not found"), 'message' => "Policy {$policyId} not found",
]; ];
continue; continue;
@ -82,32 +83,31 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe
} catch (\Throwable $e) { } catch (\Throwable $e) {
$failureSummary[] = [ $failureSummary[] = [
'code' => 'policy.sync_failed', 'code' => 'policy.sync_failed',
'message' => $bulkOperationService->sanitizeFailureReason($e->getMessage()), 'message' => $e->getMessage(),
]; ];
} }
} }
$failureCount = count($failureSummary); $failureCount = count($failureSummary);
$outcome = match (true) { $outcome = match (true) {
$failureCount === 0 => 'succeeded', $failureCount === 0 => OperationRunOutcome::Succeeded->value,
$syncedCount > 0 => 'partially_succeeded', $syncedCount > 0 => OperationRunOutcome::PartiallySucceeded->value,
default => 'failed', default => OperationRunOutcome::Failed->value,
}; };
if ($this->operationRun) { if ($this->operationRun) {
/** @var OperationRunService $opService */ $operationRunService->updateRun(
$opService = app(OperationRunService::class);
$opService->updateRun(
$this->operationRun, $this->operationRun,
'completed', status: OperationRunStatus::Completed->value,
$outcome, outcome: $outcome,
[ summaryCounts: [
'policies_total' => $ids->count(), 'total' => $ids->count(),
'policies_synced' => $syncedCount, 'processed' => $ids->count(),
'policies_skipped' => $skippedCount, 'succeeded' => $syncedCount,
'policies_failed' => $failureCount, 'failed' => $failureCount,
'skipped' => $skippedCount,
], ],
$failureSummary failures: $failureSummary,
); );
} }
@ -126,9 +126,9 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe
$failureCount = count($failures); $failureCount = count($failures);
$outcome = match (true) { $outcome = match (true) {
$failureCount === 0 => 'succeeded', $failureCount === 0 => OperationRunOutcome::Succeeded->value,
$syncedCount > 0 => 'partially_succeeded', $syncedCount > 0 => OperationRunOutcome::PartiallySucceeded->value,
default => 'failed', default => OperationRunOutcome::Failed->value,
}; };
$failureSummary = []; $failureSummary = [];
@ -157,23 +157,24 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe
$failureSummary[] = [ $failureSummary[] = [
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR', 'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR',
'message' => $bulkOperationService->sanitizeFailureReason($message), 'message' => $message,
]; ];
} }
if ($this->operationRun) { if ($this->operationRun) {
/** @var OperationRunService $opService */ $total = $syncedCount + $failureCount;
$opService = app(OperationRunService::class);
$opService->updateRun( $operationRunService->updateRun(
$this->operationRun, $this->operationRun,
'completed', status: OperationRunStatus::Completed->value,
$outcome, outcome: $outcome,
[ summaryCounts: [
'policy_types_total' => count($supported), 'total' => $total,
'policies_synced' => $syncedCount, 'processed' => $total,
'policy_types_failed' => $failureCount, 'succeeded' => $syncedCount,
'failed' => $failureCount,
], ],
$failureSummary failures: $failureSummary,
); );
} }
} }

View File

@ -51,11 +51,14 @@ public function handle(RestoreRun $restoreRun): void
[$opStatus, $opOutcome, $failures] = $this->mapStatus($status); [$opStatus, $opOutcome, $failures] = $this->mapStatus($status);
$summaryCounts = [ $summaryCounts = [];
'assignments_success' => $restoreRun->getSuccessfulAssignmentsCount(), $metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
'assignments_failed' => $restoreRun->getFailedAssignmentsCount(),
'assignments_skipped' => $restoreRun->getSkippedAssignmentsCount(), 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( $this->service->updateRun(
$opRun, $opRun,
@ -72,7 +75,7 @@ public function handle(RestoreRun $restoreRun): void
protected function mapStatus(RestoreRunStatus $status): array protected function mapStatus(RestoreRunStatus $status): array
{ {
return match ($status) { return match ($status) {
RestoreRunStatus::Previewed => ['queued', 'pending', []], RestoreRunStatus::Previewed => ['completed', 'succeeded', []],
RestoreRunStatus::Pending => ['queued', 'pending', []], RestoreRunStatus::Pending => ['queued', 'pending', []],
RestoreRunStatus::Queued => ['queued', 'pending', []], RestoreRunStatus::Queued => ['queued', 'pending', []],
RestoreRunStatus::Running => ['running', 'pending', []], RestoreRunStatus::Running => ['running', 'pending', []],

View File

@ -4,16 +4,14 @@
use App\Jobs\AddPoliciesToBackupSetJob; use App\Jobs\AddPoliciesToBackupSetJob;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\RunIdempotency;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
@ -23,7 +21,6 @@
use Filament\Tables\TableComponent; use Filament\Tables\TableComponent;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -203,7 +200,7 @@ public function table(Table $table): Table
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->exists(); ->exists();
}) })
->action(function (Collection $records, BulkOperationService $bulkOperationService): void { ->action(function (Collection $records): void {
$backupSet = BackupSet::query()->findOrFail($this->backupSetId); $backupSet = BackupSet::query()->findOrFail($this->backupSetId);
$tenant = null; $tenant = null;
@ -269,131 +266,57 @@ public function table(Table $table): Table
sort($policyIds); sort($policyIds);
$idempotencyKey = RunIdempotency::buildKey( /** @var BulkSelectionIdentity $selection */
tenantId: (int) $tenant->getKey(), $selection = app(BulkSelectionIdentity::class);
operationType: 'backup_set.add_policies', $selectionIdentity = $selection->fromIds($policyIds);
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,
],
);
// --- Phase 3: Canonical Operation Run Start ---
/** @var OperationRunService $opService */ /** @var OperationRunService $opService */
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRun( $opRun = $opService->enqueueBulkOperation(
tenant: $tenant, tenant: $tenant,
type: 'backup_set.add_policies', type: 'backup_set.add_policies',
inputs: [ targetScope: [
'backup_set_id' => $backupSet->id, 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_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,
],
], ],
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'])) { AddPoliciesToBackupSetJob::dispatch(
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(
tenantId: (int) $tenant->getKey(), 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) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make() Notification::make()
->title('Add policies already queued') ->title('Add policies already queued')
->body('A matching run is already queued or running. Open the run to monitor progress.') ->body('A matching run is already queued or running. Open the run to monitor progress.')
->actions([ ->actions([
\Filament\Actions\Action::make('view_run') \Filament\Actions\Action::make('view_run')
->label('View run') ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->info() ->info()
->send(); ->send();
return; return;
}
}
throw $exception;
} }
/** @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) OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([ ->actions([
\Filament\Actions\Action::make('view_run') \Filament\Actions\Action::make('view_run')

View File

@ -1,104 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BulkOperationRun extends Model
{
use HasFactory;
protected $fillable = [
'tenant_id',
'user_id',
'resource',
'action',
'idempotency_key',
'status',
'total_items',
'processed_items',
'succeeded',
'failed',
'skipped',
'item_ids',
'failures',
'audit_log_id',
];
protected $casts = [
'item_ids' => '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',
};
}
}

View File

@ -121,7 +121,7 @@ public function getAssignmentRestoreOutcomes(): array
return collect($results) return collect($results)
->pluck('assignment_outcomes') ->pluck('assignment_outcomes')
->flatten(1) ->flatten(1)
->filter() ->filter(static fn (mixed $outcome): bool => is_array($outcome))
->values() ->values()
->all(); ->all();
} }
@ -130,7 +130,7 @@ public function getSuccessfulAssignmentsCount(): int
{ {
return count(array_filter( return count(array_filter(
$this->getAssignmentRestoreOutcomes(), $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( return count(array_filter(
$this->getAssignmentRestoreOutcomes(), $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( return count(array_filter(
$this->getAssignmentRestoreOutcomes(), $this->getAssignmentRestoreOutcomes(),
fn ($outcome) => $outcome['status'] === 'skipped' static fn (mixed $outcome): bool => is_array($outcome) && ($outcome['status'] ?? null) === 'skipped'
)); ));
} }
} }

View File

@ -2,10 +2,10 @@
namespace App\Notifications; namespace App\Notifications;
use App\Filament\Resources\BulkOperationRunResource;
use App\Filament\Resources\EntraGroupSyncRunResource; use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OperationRunLinks;
use Filament\Actions\Action; use Filament\Actions\Action;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
@ -66,7 +66,7 @@ public function toDatabase(object $notifiable): array
if ($tenant) { if ($tenant) {
$url = match ($runType) { $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), 'restore' => RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
'directory_groups' => EntraGroupSyncRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), 'directory_groups' => EntraGroupSyncRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
default => null, default => null,

View File

@ -1,39 +0,0 @@
<?php
namespace App\Policies;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class BulkOperationRunPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
$tenant = Tenant::current();
if (! $tenant) {
return false;
}
return $user->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();
}
}

View File

@ -6,6 +6,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization; use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Auth\Access\Response;
class OperationRunPolicy class OperationRunPolicy
{ {
@ -22,7 +23,7 @@ public function viewAny(User $user): bool
return $user->canAccessTenant($tenant); return $user->canAccessTenant($tenant);
} }
public function view(User $user, OperationRun $run): bool public function view(User $user, OperationRun $run): Response|bool
{ {
$tenant = Tenant::current(); $tenant = Tenant::current();
@ -34,6 +35,10 @@ public function view(User $user, OperationRun $run): bool
return false; return false;
} }
return (int) $run->tenant_id === (int) $tenant->getKey(); if ((int) $run->tenant_id !== (int) $tenant->getKey()) {
return Response::denyAsNotFound();
}
return true;
} }
} }

View File

@ -3,7 +3,6 @@
namespace App\Providers; namespace App\Providers;
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BulkOperationRun;
use App\Models\EntraGroup; use App\Models\EntraGroup;
use App\Models\EntraGroupSyncRun; use App\Models\EntraGroupSyncRun;
use App\Models\Finding; use App\Models\Finding;
@ -14,7 +13,6 @@
use App\Models\UserTenantPreference; use App\Models\UserTenantPreference;
use App\Observers\RestoreRunObserver; use App\Observers\RestoreRunObserver;
use App\Policies\BackupSchedulePolicy; use App\Policies\BackupSchedulePolicy;
use App\Policies\BulkOperationRunPolicy;
use App\Policies\EntraGroupPolicy; use App\Policies\EntraGroupPolicy;
use App\Policies\EntraGroupSyncRunPolicy; use App\Policies\EntraGroupSyncRunPolicy;
use App\Policies\FindingPolicy; use App\Policies\FindingPolicy;
@ -121,7 +119,6 @@ public function boot(): void
}); });
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class); Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class);
Gate::policy(Finding::class, FindingPolicy::class); Gate::policy(Finding::class, FindingPolicy::class);
Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class); Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class);
Gate::policy(EntraGroup::class, EntraGroupPolicy::class); Gate::policy(EntraGroup::class, EntraGroupPolicy::class);

View File

@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\RestoreRunStatus;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
final class AdapterRunReconciler
{
/**
* @return array<int, string>
*/
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<int,array<string,mixed>>}
*/
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<string, mixed>|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<int,array{code:string,message:string}>}
*/
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<string, int>
*/
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;
}
}

View File

@ -1,269 +0,0 @@
<?php
namespace App\Services;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\AuditLogger;
class BulkOperationService
{
public function __construct(
protected AuditLogger $auditLogger
) {}
public function sanitizeFailureReason(string $reason): string
{
$reason = trim($reason);
if ($reason === '') {
return 'error';
}
$lower = mb_strtolower($reason);
if (
str_contains($lower, 'bearer ') ||
str_contains($lower, 'access_token') ||
str_contains($lower, 'client_secret') ||
str_contains($lower, 'authorization')
) {
return 'redacted';
}
$reason = preg_replace("/\s+/u", ' ', $reason) ?? $reason;
return mb_substr($reason, 0, 200);
}
public function createRun(
Tenant $tenant,
User $user,
string $resource,
string $action,
array $itemIds,
int $totalItems,
?string $idempotencyKey = null
): BulkOperationRun {
$effectiveTotalItems = $totalItems;
if (array_is_list($itemIds)) {
$effectiveTotalItems = max($totalItems, count($itemIds));
}
$run = BulkOperationRun::create([
'tenant_id' => $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
);
}
}

View File

@ -90,6 +90,29 @@ public function listPolicies(string $policyType, array $options = []): GraphResp
$response = $this->send('GET', $endpoint, $sendOptions, $context); $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()) { if ($response->failed()) {
$graphResponse = $this->toGraphResponse( $graphResponse = $this->toGraphResponse(
action: 'list_policies', action: 'list_policies',
@ -626,7 +649,24 @@ private function send(string $method, string $path, array $options = [], array $
$pending = Http::baseUrl($this->baseUrl) $pending = Http::baseUrl($this->baseUrl)
->acceptJson() ->acceptJson()
->timeout($this->timeout) ->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) ->withToken($token)
->withHeaders([ ->withHeaders([
'client-request-id' => $clientRequestId, 'client-request-id' => $clientRequestId,
@ -667,6 +707,23 @@ private function send(string $method, string $path, array $options = [], array $
return $response; 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 private function toGraphResponse(string $action, Response $response, callable $transform, array $meta = [], array $warnings = []): GraphResponse
{ {
$json = $response->json() ?? []; $json = $response->json() ?? [];

View File

@ -7,11 +7,17 @@
use App\Models\User; use App\Models\User;
use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification; use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification;
use App\Notifications\OperationRunQueued as OperationRunQueuedNotification; use App\Notifications\OperationRunQueued as OperationRunQueuedNotification;
use App\Services\Operations\BulkIdempotencyFingerprint;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\BulkRunContext;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\OpsUx\SummaryCountsNormalizer; use App\Support\OpsUx\SummaryCountsNormalizer;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException; use InvalidArgumentException;
use ReflectionFunction;
use ReflectionMethod;
use Throwable; use Throwable;
class OperationRunService 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<string, mixed> $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( public function updateRun(
OperationRun $run, OperationRun $run,
string $status, string $status,
@ -137,6 +261,51 @@ public function updateRun(
return $run; 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<string, mixed> $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. * Dispatch a queued operation safely.
* *
@ -146,7 +315,7 @@ public function updateRun(
public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = true): void public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = true): void
{ {
try { try {
$dispatcher(); $this->invokeDispatcher($dispatcher, $run);
if ($emitQueuedNotification && $run->wasRecentlyCreated && $run->user instanceof User) { if ($emitQueuedNotification && $run->wasRecentlyCreated && $run->user instanceof User) {
$run->user->notify(new OperationRunQueuedNotification($run)); $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<int, array{code?: mixed, message?: mixed}> $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 public function failRun(OperationRun $run, Throwable $e): OperationRun
{ {
return $this->updateRun( 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 protected function calculateHash(int $tenantId, string $type, array $inputs): string
{ {
$normalizedInputs = $this->normalizeInputs($inputs); $normalizedInputs = $this->normalizeInputs($inputs);
@ -237,7 +531,7 @@ protected function isListArray(array $array): bool
/** /**
* @param array<int, array{code?: mixed, message?: mixed}> $failures * @param array<int, array{code?: mixed, message?: mixed}> $failures
* @return array<int, array{code: string, message: string}> * @return array<int, array{code: string, reason_code: string, message: string}>
*/ */
protected function sanitizeFailures(array $failures): array protected function sanitizeFailures(array $failures): array
{ {
@ -245,10 +539,12 @@ protected function sanitizeFailures(array $failures): array
foreach ($failures as $failure) { foreach ($failures as $failure) {
$code = (string) ($failure['code'] ?? 'unknown'); $code = (string) ($failure['code'] ?? 'unknown');
$reasonCode = (string) ($failure['reason_code'] ?? $code);
$message = (string) ($failure['message'] ?? ''); $message = (string) ($failure['message'] ?? '');
$sanitized[] = [ $sanitized[] = [
'code' => $this->sanitizeFailureCode($code), 'code' => $this->sanitizeFailureCode($code),
'reason_code' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
'message' => $this->sanitizeMessage($message), 'message' => $this->sanitizeMessage($message),
]; ];
} }
@ -258,27 +554,12 @@ protected function sanitizeFailures(array $failures): array
protected function sanitizeFailureCode(string $code): string protected function sanitizeFailureCode(string $code): string
{ {
$code = strtolower(trim($code)); return RunFailureSanitizer::sanitizeCode($code);
if ($code === '') {
return 'unknown';
}
return substr($code, 0, 80);
} }
protected function sanitizeMessage(string $message): string protected function sanitizeMessage(string $message): string
{ {
$message = trim(str_replace(["\r", "\n"], ' ', $message)); return RunFailureSanitizer::sanitizeMessage($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);
} }
/** /**

View File

@ -0,0 +1,43 @@
<?php
namespace App\Services\Operations;
final class BulkIdempotencyFingerprint
{
/**
* @param array<string, mixed> $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;
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Services\Operations;
final class BulkSelectionIdentity
{
/**
* @param array<int, mixed> $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<string, mixed> $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<string, mixed> $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;
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Services\Operations;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
final class TargetScopeConcurrencyLimiter
{
public function __construct(private readonly int $lockTtlSeconds = 900) {}
/**
* Acquire a concurrency slot for a given tenant + target scope.
*
* Returns a held lock when a slot is available, otherwise null.
*
* @param array{entra_tenant_id?: mixed, directory_context_id?: mixed} $targetScope
*/
public function acquireSlot(int $tenantId, array $targetScope): ?Lock
{
$max = (int) config('tenantpilot.bulk_operations.concurrency.per_target_scope_max', 1);
$max = max(0, $max);
if ($max === 0) {
return null;
}
$scopeKey = $this->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.');
}
}

View File

@ -15,6 +15,9 @@ public static function labels(): array
'policy.sync' => 'Policy sync', 'policy.sync' => 'Policy sync',
'policy.sync_one' => 'Policy sync', 'policy.sync_one' => 'Policy sync',
'policy.capture_snapshot' => 'Policy snapshot', 'policy.capture_snapshot' => 'Policy snapshot',
'policy.delete' => 'Delete policies',
'policy.unignore' => 'Restore policies',
'policy.export' => 'Export policies to backup',
'inventory.sync' => 'Inventory sync', 'inventory.sync' => 'Inventory sync',
'directory_groups.sync' => 'Directory groups sync', 'directory_groups.sync' => 'Directory groups sync',
'drift.generate' => 'Drift generation', 'drift.generate' => 'Drift generation',
@ -26,6 +29,10 @@ public static function labels(): array
'backup_schedule.run_now' => 'Backup schedule run', 'backup_schedule.run_now' => 'Backup schedule run',
'backup_schedule.retry' => 'Backup schedule retry', 'backup_schedule.retry' => 'Backup schedule retry',
'restore.execute' => 'Restore execution', '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.prune' => 'Prune policy versions',
'policy_version.restore' => 'Restore policy versions', 'policy_version.restore' => 'Restore policy versions',
'policy_version.force_delete' => 'Delete policy versions', 'policy_version.force_delete' => 'Delete policy versions',
@ -47,6 +54,7 @@ public static function expectedDurationSeconds(string $operationType): ?int
{ {
return match (trim($operationType)) { return match (trim($operationType)) {
'policy.sync', 'policy.sync_one' => 90, 'policy.sync', 'policy.sync_one' => 90,
'policy.export' => 120,
'inventory.sync' => 180, 'inventory.sync' => 180,
'directory_groups.sync' => 120, 'directory_groups.sync' => 120,
'drift.generate' => 240, 'drift.generate' => 240,

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use InvalidArgumentException;
final class BulkRunContext
{
/**
* @param array{entra_tenant_id?: mixed, directory_context_id?: mixed} $targetScope
* @param array{kind: string, ids_hash?: string, query_hash?: string} $selectionIdentity
* @param array<string, mixed> $extra
* @return array<string, mixed>
*/
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;
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Support\OpsUx;
final class RunFailureSanitizer
{
public const string REASON_GRAPH_THROTTLED = 'graph_throttled';
public const string REASON_GRAPH_TIMEOUT = 'graph_timeout';
public const string REASON_PERMISSION_DENIED = 'permission_denied';
public const string REASON_VALIDATION_ERROR = 'validation_error';
public const string REASON_CONFLICT_DETECTED = 'conflict_detected';
public const string REASON_UNKNOWN_ERROR = 'unknown_error';
public static function sanitizeCode(string $code): string
{
$code = strtolower(trim($code));
if ($code === '') {
return 'unknown';
}
return substr($code, 0, 80);
}
public static function normalizeReasonCode(string $candidate): string
{
$candidate = strtolower(trim($candidate));
if ($candidate === '') {
return self::REASON_UNKNOWN_ERROR;
}
$allowed = [
self::REASON_GRAPH_THROTTLED,
self::REASON_GRAPH_TIMEOUT,
self::REASON_PERMISSION_DENIED,
self::REASON_VALIDATION_ERROR,
self::REASON_CONFLICT_DETECTED,
self::REASON_UNKNOWN_ERROR,
];
if (in_array($candidate, $allowed, true)) {
return $candidate;
}
// Compatibility mappings from existing codebase labels.
$candidate = match ($candidate) {
'graph_forbidden' => 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);
}
}

View File

@ -2,11 +2,10 @@
namespace App\Support; namespace App\Support;
use App\Models\BulkOperationRun;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
final class RunIdempotency final class RestoreRunIdempotency
{ {
/** /**
* @param array<string, mixed> $context * @param array<string, mixed> $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)); 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 public static function findActiveRestoreRun(int $tenantId, string $idempotencyKey): ?RestoreRun
{ {
return RestoreRun::query() return RestoreRun::query()

View File

@ -320,6 +320,10 @@
'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3), 'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3),
'recent_finished_seconds' => (int) env('TENANTPILOT_BULK_RECENT_FINISHED_SECONDS', 12), 'recent_finished_seconds' => (int) env('TENANTPILOT_BULK_RECENT_FINISHED_SECONDS', 12),
'progress_widget_enabled' => (bool) env('TENANTPILOT_BULK_PROGRESS_WIDGET_ENABLED', true), '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' => [ 'inventory_sync' => [

View File

@ -1,34 +0,0 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BulkOperationRun>
*/
class BulkOperationRunFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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' => [],
];
}
}

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::dropIfExists('bulk_operation_runs');
}
public function down(): void
{
Schema::create('bulk_operation_runs', function (Blueprint $table) {
$table->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')");
}
};

View File

@ -1,33 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class BulkOperationsTestSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$tenant = \App\Models\Tenant::first() ?? \App\Models\Tenant::factory()->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,
]);
}
}

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Jobs\PruneOldOperationRunsJob; use App\Jobs\PruneOldOperationRunsJob;
use App\Jobs\ReconcileAdapterRunsJob;
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule; use Illuminate\Support\Facades\Schedule;
@ -16,3 +17,8 @@
->daily() ->daily()
->name(PruneOldOperationRunsJob::class) ->name(PruneOldOperationRunsJob::class)
->withoutOverlapping(); ->withoutOverlapping();
Schedule::job(new ReconcileAdapterRunsJob)
->everyThirtyMinutes()
->name(ReconcileAdapterRunsJob::class)
->withoutOverlapping();

View File

@ -44,7 +44,8 @@ ### 2. Run Migrations
### 3. Seed Test Data (Optional) ### 3. Seed Test Data (Optional)
```bash ```bash
./vendor/bin/sail artisan db:seed --class=BulkOperationsTestSeeder # NOTE: Removed by Feature 056 (OperationRun migration).
# There is no BulkOperationsTestSeeder anymore.
``` ```
Creates: Creates:
@ -409,10 +410,10 @@ # Watch queue jobs in real-time
# Monitor bulk operations # Monitor bulk operations
./vendor/bin/sail artisan tinker ./vendor/bin/sail artisan tinker
>>> BulkOperationRun::inProgress()->get() >>> \App\Models\OperationRun::query()->where('status', 'running')->get()
# Seed more test data # Seed more test data
./vendor/bin/sail artisan db:seed --class=BulkOperationsTestSeeder # NOTE: Removed by Feature 056 (OperationRun migration).
# Clear cache # Clear cache
./vendor/bin/sail artisan optimize:clear ./vendor/bin/sail artisan optimize:clear

View File

@ -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.

View File

@ -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"]
}

View File

@ -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]

View File

@ -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.

View File

@ -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. |

View File

@ -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.

View File

@ -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`

View File

@ -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.

View File

@ -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 Monitorings 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 cant 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 runs 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.

View File

@ -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 Monitorings 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 cant 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.

View File

@ -4,7 +4,6 @@
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun; use App\Models\BackupScheduleRun;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Services\BulkOperationService;
use App\Services\Intune\BackupService; use App\Services\Intune\BackupService;
use App\Services\Intune\PolicySyncService; use App\Services\Intune\PolicySyncService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
@ -75,14 +74,13 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
Cache::flush(); Cache::flush();
(new RunBackupScheduleJob($run->id, null, $operationRun))->handle( (new RunBackupScheduleJob($run->id, $operationRun))->handle(
app(PolicySyncService::class), app(PolicySyncService::class),
app(BackupService::class), app(BackupService::class),
app(\App\Services\BackupScheduling\PolicyTypeResolver::class), app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
app(\App\Services\BackupScheduling\ScheduleTimeService::class), app(\App\Services\BackupScheduling\ScheduleTimeService::class),
app(\App\Services\Intune\AuditLogger::class), app(\App\Services\Intune\AuditLogger::class),
app(\App\Services\BackupScheduling\RunErrorMapper::class), app(\App\Services\BackupScheduling\RunErrorMapper::class),
app(BulkOperationService::class),
); );
$run->refresh(); $run->refresh();
@ -92,11 +90,14 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
$operationRun->refresh(); $operationRun->refresh();
expect($operationRun->status)->toBe('completed'); expect($operationRun->status)->toBe('completed');
expect($operationRun->outcome)->toBe('succeeded'); expect($operationRun->outcome)->toBe('succeeded');
expect($operationRun->summary_counts)->toMatchArray([ expect($operationRun->context)->toMatchArray([
'backup_schedule_id' => (int) $schedule->id, 'backup_schedule_id' => (int) $schedule->id,
'backup_schedule_run_id' => (int) $run->id, 'backup_schedule_run_id' => (int) $run->id,
'backup_set_id' => (int) $backupSet->id, 'backup_set_id' => (int) $backupSet->id,
]); ]);
expect($operationRun->summary_counts)->toMatchArray([
'created' => 1,
]);
}); });
it('skips runs when all policy types are unknown', function () { it('skips runs when all policy types are unknown', function () {
@ -137,14 +138,13 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
initiator: $user, initiator: $user,
); );
(new RunBackupScheduleJob($run->id, null, $operationRun))->handle( (new RunBackupScheduleJob($run->id, $operationRun))->handle(
app(PolicySyncService::class), app(PolicySyncService::class),
app(BackupService::class), app(BackupService::class),
app(\App\Services\BackupScheduling\PolicyTypeResolver::class), app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
app(\App\Services\BackupScheduling\ScheduleTimeService::class), app(\App\Services\BackupScheduling\ScheduleTimeService::class),
app(\App\Services\Intune\AuditLogger::class), app(\App\Services\Intune\AuditLogger::class),
app(\App\Services\BackupScheduling\RunErrorMapper::class), app(\App\Services\BackupScheduling\RunErrorMapper::class),
app(BulkOperationService::class),
); );
$run->refresh(); $run->refresh();
@ -156,7 +156,7 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
expect($operationRun->status)->toBe('completed'); expect($operationRun->status)->toBe('completed');
expect($operationRun->outcome)->toBe('failed'); expect($operationRun->outcome)->toBe('failed');
expect($operationRun->failure_summary)->toMatchArray([ 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\BackupScheduling\ScheduleTimeService::class),
app(\App\Services\Intune\AuditLogger::class), app(\App\Services\Intune\AuditLogger::class),
app(\App\Services\BackupScheduling\RunErrorMapper::class), app(\App\Services\BackupScheduling\RunErrorMapper::class),
app(BulkOperationService::class),
); );
$operationRun->refresh(); $operationRun->refresh();
expect($operationRun->status)->toBe('completed'); expect($operationRun->status)->toBe('completed');
expect($operationRun->outcome)->toBe('succeeded'); 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_schedule_run_id' => (int) $run->id,
'backup_set_id' => (int) $backupSet->id, 'backup_set_id' => (int) $backupSet->id,
]); ]);
expect($operationRun->summary_counts)->toMatchArray([
'created' => 1,
]);
}); });

View File

@ -4,7 +4,6 @@
use App\Jobs\RunBackupScheduleJob; use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun; use App\Models\BackupScheduleRun;
use App\Models\BulkOperationRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\User; use App\Models\User;
use App\Notifications\OperationRunQueued; use App\Notifications\OperationRunQueued;
@ -68,14 +67,6 @@
'backup_schedule_run_id' => (int) $run->id, '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 { Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
return $job->backupScheduleRunId === (int) $run->id return $job->backupScheduleRunId === (int) $run->id
&& $job->operationRun instanceof OperationRun && $job->operationRun instanceof OperationRun
@ -139,14 +130,6 @@
'backup_schedule_run_id' => (int) $run->id, '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 { Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
return $job->backupScheduleRunId === (int) $run->id return $job->backupScheduleRunId === (int) $run->id
&& $job->operationRun instanceof OperationRun && $job->operationRun instanceof OperationRun
@ -259,14 +242,6 @@
->count()) ->count())
->toBe(2); ->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); Queue::assertPushed(RunBackupScheduleJob::class, 2);
$this->assertDatabaseCount('notifications', 1); $this->assertDatabaseCount('notifications', 1);
$this->assertDatabaseHas('notifications', [ $this->assertDatabaseHas('notifications', [
@ -330,14 +305,6 @@
->count()) ->count())
->toBe(2); ->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); Queue::assertPushed(RunBackupScheduleJob::class, 2);
$this->assertDatabaseCount('notifications', 1); $this->assertDatabaseCount('notifications', 1);
$this->assertDatabaseHas('notifications', [ $this->assertDatabaseHas('notifications', [

View File

@ -3,13 +3,13 @@
use App\Jobs\AddPoliciesToBackupSetJob; use App\Jobs\AddPoliciesToBackupSetJob;
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\BulkOperationRun; use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Services\BulkOperationService;
use App\Services\Intune\FoundationSnapshotService; use App\Services\Intune\FoundationSnapshotService;
use App\Services\Intune\PolicyCaptureOrchestrator; use App\Services\Intune\PolicyCaptureOrchestrator;
use App\Services\Intune\SnapshotValidator; use App\Services\Intune\SnapshotValidator;
use App\Services\OperationRunService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface; use Mockery\MockInterface;
@ -44,23 +44,19 @@
'snapshot' => ['id' => $policyA->external_id], 'snapshot' => ['id' => $policyA->external_id],
]); ]);
$run = BulkOperationRun::factory()->create([ $run = OperationRun::factory()->create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'user_id' => $user->id, 'user_id' => $user->id,
'resource' => 'backup_set', 'initiator_name' => $user->name,
'action' => 'add_policies', 'type' => 'backup_set.add_policies',
'status' => 'pending', 'status' => 'queued',
'total_items' => 2, 'outcome' => 'pending',
'item_ids' => [ 'context' => [
'backup_set_id' => $backupSet->id, 'backup_set_id' => (int) $backupSet->getKey(),
'policy_ids' => [$policyA->id, $policyB->id], 'policy_ids' => [(int) $policyA->getKey(), (int) $policyB->getKey()],
'options' => [
'include_assignments' => true,
'include_scope_tags' => true,
'include_foundations' => false,
],
], ],
'failures' => [], 'summary_counts' => [],
'failure_summary' => [],
]); ]);
$this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($policyA, $policyB, $tenant, $versionA) { $this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($policyA, $policyB, $tenant, $versionA) {
@ -107,15 +103,21 @@
}); });
$job = new AddPoliciesToBackupSetJob( $job = new AddPoliciesToBackupSetJob(
bulkRunId: (int) $run->getKey(), tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
backupSetId: (int) $backupSet->getKey(), backupSetId: (int) $backupSet->getKey(),
includeAssignments: true, policyIds: [(int) $policyA->getKey(), (int) $policyB->getKey()],
includeScopeTags: true, options: [
includeFoundations: false, 'include_assignments' => true,
'include_scope_tags' => true,
'include_foundations' => false,
],
idempotencyKey: 'test-idempotency-key',
operationRun: $run,
); );
$job->handle( $job->handle(
bulkOperationService: app(BulkOperationService::class), operationRunService: app(OperationRunService::class),
captureOrchestrator: app(PolicyCaptureOrchestrator::class), captureOrchestrator: app(PolicyCaptureOrchestrator::class),
foundationSnapshots: $this->mock(FoundationSnapshotService::class), foundationSnapshots: $this->mock(FoundationSnapshotService::class),
snapshotValidator: app(SnapshotValidator::class), snapshotValidator: app(SnapshotValidator::class),
@ -124,23 +126,23 @@
$run->refresh(); $run->refresh();
$backupSet->refresh(); $backupSet->refresh();
expect($run->status)->toBe('completed_with_errors'); expect($run->status)->toBe('completed');
expect($run->total_items)->toBe(2); expect($run->outcome)->toBe('partially_succeeded');
expect($run->processed_items)->toBe(2); expect((int) ($run->summary_counts['total'] ?? 0))->toBe(2);
expect($run->succeeded)->toBe(1); expect((int) ($run->summary_counts['processed'] ?? 0))->toBe(2);
expect($run->failed)->toBe(1); expect((int) ($run->summary_counts['succeeded'] ?? 0))->toBe(1);
expect($run->skipped)->toBe(0); expect((int) ($run->summary_counts['failed'] ?? 0))->toBe(1);
expect((int) ($run->summary_counts['skipped'] ?? 0))->toBe(0);
expect(BackupItem::query() expect(BackupItem::query()
->where('backup_set_id', $backupSet->id) ->where('backup_set_id', $backupSet->id)
->where('policy_id', $policyA->id) ->where('policy_id', $policyA->id)
->exists())->toBeTrue(); ->exists())->toBeTrue();
$failureEntry = collect($run->failures ?? []) $failureEntry = collect($run->failure_summary ?? [])
->firstWhere('item_id', (string) $policyB->id); ->first(fn ($entry): bool => is_array($entry) && (($entry['code'] ?? null) === 'graph.graph_forbidden'));
expect($failureEntry)->not->toBeNull(); expect($failureEntry)->not->toBeNull();
expect($failureEntry['reason_code'] ?? null)->toBe('graph_forbidden');
expect($backupSet->status)->toBe('partial'); expect($backupSet->status)->toBe('partial');
}); });

View File

@ -4,7 +4,6 @@
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use Filament\Notifications\DatabaseNotification; use Filament\Notifications\DatabaseNotification;
@ -50,7 +49,7 @@
operationRun: $opRun, operationRun: $opRun,
); );
$job->handle(app(AuditLogger::class), app(BulkOperationService::class)); $job->handle(app(AuditLogger::class));
$this->assertDatabaseHas('notifications', [ $this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->getKey(), 'notifiable_id' => $user->getKey(),

View File

@ -3,7 +3,7 @@
use App\Filament\Resources\BackupSetResource; use App\Filament\Resources\BackupSetResource;
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\BulkOperationRun; use App\Models\OperationRun;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@ -51,14 +51,15 @@
$sets->each(fn (BackupSet $set) => expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue()); $sets->each(fn (BackupSet $set) => expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue());
$bulkRun = BulkOperationRun::query() $opRun = OperationRun::query()
->where('resource', 'backup_set') ->where('tenant_id', $tenant->id)
->where('action', 'delete') ->where('user_id', $user->id)
->where('type', 'backup_set.delete')
->latest('id') ->latest('id')
->first(); ->first();
expect($bulkRun)->not->toBeNull(); expect($opRun)->not->toBeNull();
expect($bulkRun->status)->toBe('completed'); expect($opRun->status)->toBe('completed');
}); });
test('backup sets can be archived even when referenced by restore runs', function () { test('backup sets can be archived even when referenced by restore runs', function () {

View File

@ -2,7 +2,7 @@
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\BulkOperationRun; use App\Models\OperationRun;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@ -56,12 +56,14 @@
$completedRuns->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue()); $completedRuns->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue());
expect(RestoreRun::withTrashed()->find($running->id)?->trashed())->toBeFalse(); expect(RestoreRun::withTrashed()->find($running->id)?->trashed())->toBeFalse();
$bulkRun = BulkOperationRun::query() $opRun = OperationRun::query()
->where('resource', 'restore_run') ->where('tenant_id', $tenant->id)
->where('action', 'delete') ->where('user_id', $user->id)
->where('type', 'restore_run.delete')
->latest('id') ->latest('id')
->first(); ->first();
expect($bulkRun)->not->toBeNull(); expect($opRun)->not->toBeNull();
expect($bulkRun->skipped)->toBeGreaterThanOrEqual(1); $counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : [];
expect((int) ($counts['skipped'] ?? 0))->toBeGreaterThanOrEqual(1);
}); });

View File

@ -4,7 +4,7 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\BulkOperationService; use App\Services\OperationRunService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
@ -18,13 +18,31 @@
$policies = Policy::factory()->count(25)->create(['tenant_id' => $tenant->id]); $policies = Policy::factory()->count(25)->create(['tenant_id' => $tenant->id]);
$policyIds = $policies->pluck('id')->toArray(); $policyIds = $policies->pluck('id')->toArray();
$service = app(BulkOperationService::class); /** @var OperationRunService $service */
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 25); $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) // 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) { Queue::assertPushed(BulkPolicyDeleteJob::class, function ($job) use ($tenant, $user, $opRun, $policyIds) {
return $job->bulkRunId === $run->id; return $job->tenantId === (int) $tenant->getKey()
&& $job->userId === (int) $user->getKey()
&& $job->operationRun?->getKey() === $opRun->getKey()
&& $job->policyIds === $policyIds;
}); });
}); });

View File

@ -1,10 +1,12 @@
<?php <?php
use App\Jobs\BulkPolicyDeleteJob; use App\Jobs\BulkPolicyDeleteJob;
use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\BulkOperationService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -15,18 +17,45 @@
$policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]); $policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]);
$policyIds = $policies->pluck('id')->toArray(); $policyIds = $policies->pluck('id')->toArray();
$service = app(BulkOperationService::class); /** @var OperationRunService $service */
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 10); $service = app(OperationRunService::class);
// Simulate Sync execution /** @var BulkSelectionIdentity $selection */
BulkPolicyDeleteJob::dispatchSync($run->id); $selection = app(BulkSelectionIdentity::class);
$run->refresh(); $selectionIdentity = $selection->fromIds($policyIds);
expect($run->status)->toBe('completed')
->and($run->processed_items)->toBe(10)
->and($run->audit_log_id)->not->toBeNull();
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) { $policies->each(function ($policy) {
expect($policy->refresh()->ignored_at)->not->toBeNull(); expect($policy->refresh()->ignored_at)->not->toBeNull();

View File

@ -2,7 +2,7 @@
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\BulkOperationRun; use App\Models\OperationRun;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@ -45,12 +45,13 @@
$runs->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue()); $runs->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue());
$bulkRun = BulkOperationRun::query() $opRun = OperationRun::query()
->where('resource', 'restore_run') ->where('tenant_id', $tenant->id)
->where('action', 'delete') ->where('user_id', $user->id)
->where('type', 'restore_run.delete')
->latest('id') ->latest('id')
->first(); ->first();
expect($bulkRun)->not->toBeNull(); expect($opRun)->not->toBeNull();
expect($bulkRun->status)->toBe('completed'); expect($opRun->status)->toBe('completed');
}); });

View File

@ -1,11 +1,12 @@
<?php <?php
use App\Jobs\BulkPolicyExportJob; use App\Jobs\BulkPolicyExportJob;
use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\BulkOperationService; use App\Services\OperationRunService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -26,23 +27,38 @@
$missingVersionPolicy = Policy::factory()->create(['tenant_id' => $tenant->id]); $missingVersionPolicy = Policy::factory()->create(['tenant_id' => $tenant->id]);
$service = app(BulkOperationService::class); /** @var OperationRunService $service */
$run = $service->createRun( $service = app(OperationRunService::class);
$tenant,
$user, $opRun = $service->ensureRun(
'policy', tenant: $tenant,
'export', type: 'policy.export',
[$okPolicy->id, $missingVersionPolicy->id], inputs: [
2 '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(); $opRun->refresh();
expect($run->status)->toBe('completed_with_errors') expect($opRun)->toBeInstanceOf(OperationRun::class);
->and($run->succeeded)->toBe(1) expect($opRun->status)->toBe('completed');
->and($run->failed)->toBe(1) expect($opRun->outcome)->toBe('partially_succeeded');
->and($run->processed_items)->toBe(2); expect($opRun->summary_counts)->toMatchArray([
'total' => 2,
'processed' => 2,
'succeeded' => 1,
'failed' => 1,
'created' => 1,
]);
$this->assertDatabaseHas('backup_sets', [ $this->assertDatabaseHas('backup_sets', [
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,

View File

@ -1,11 +1,12 @@
<?php <?php
use App\Jobs\BulkPolicyExportJob; use App\Jobs\BulkPolicyExportJob;
use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\BulkOperationService; use App\Services\OperationRunService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -24,15 +25,33 @@
'captured_at' => now(), 'captured_at' => now(),
]); ]);
$service = app(BulkOperationService::class); $opRun = OperationRun::create([
$run = $service->createRun($tenant, $user, 'policy', 'export', [$policy->id], 1); '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 // Simulate Sync
$job = new BulkPolicyExportJob($run->id, 'Feature Backup'); $job = new BulkPolicyExportJob(
$job->handle($service); 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(); $opRun->refresh();
expect($run->status)->toBe('completed'); expect($opRun->status)->toBe('completed');
$this->assertDatabaseHas('backup_sets', [ $this->assertDatabaseHas('backup_sets', [
'name' => 'Feature Backup', 'name' => 'Feature Backup',

View File

@ -3,7 +3,7 @@
use App\Filament\Resources\BackupSetResource; use App\Filament\Resources\BackupSetResource;
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\BulkOperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -50,12 +50,13 @@
expect(BackupSet::withTrashed()->find($set->id))->toBeNull(); expect(BackupSet::withTrashed()->find($set->id))->toBeNull();
expect(BackupItem::withTrashed()->find($item->id))->toBeNull(); expect(BackupItem::withTrashed()->find($item->id))->toBeNull();
$bulkRun = BulkOperationRun::query() $opRun = OperationRun::query()
->where('resource', 'backup_set') ->where('tenant_id', $tenant->id)
->where('action', 'force_delete') ->where('user_id', $user->id)
->where('type', 'backup_set.force_delete')
->latest('id') ->latest('id')
->first(); ->first();
expect($bulkRun)->not->toBeNull(); expect($opRun)->not->toBeNull();
expect($bulkRun->status)->toBe('completed'); expect($opRun->status)->toBe('completed');
}); });

View File

@ -1,7 +1,7 @@
<?php <?php
use App\Filament\Resources\PolicyVersionResource; use App\Filament\Resources\PolicyVersionResource;
use App\Models\BulkOperationRun; use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
@ -38,18 +38,19 @@
]) ])
->assertHasNoTableBulkActionErrors(); ->assertHasNoTableBulkActionErrors();
$run = BulkOperationRun::query() $run = OperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('user_id', $user->id) ->where('user_id', $user->id)
->where('resource', 'policy_version') ->where('type', 'policy_version.force_delete')
->where('action', 'force_delete')
->latest('id') ->latest('id')
->first(); ->first();
expect($run)->not->toBeNull(); expect($run)->not->toBeNull();
expect($run->succeeded)->toBe(1)
->and($run->skipped)->toBe(0) $counts = is_array($run->summary_counts) ? $run->summary_counts : [];
->and($run->failed)->toBe(0); 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(); expect(PolicyVersion::withTrashed()->whereKey($version->id)->exists())->toBeFalse();
}); });

Some files were not shown because too many files have changed in this diff Show More