wip: feature 056 progress

This commit is contained in:
Ahmed Darrazi 2026-01-19 18:50:11 +01:00
parent bcdeeb5525
commit 5118497da9
143 changed files with 7867 additions and 4635 deletions

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

View File

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

View File

@ -5,19 +5,17 @@
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\InventorySyncRunResource;
use App\Jobs\GenerateDriftFindingsJob;
use App\Models\BulkOperationRun;
use App\Models\Finding;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Drift\DriftRunSelector;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\RunIdempotency;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
@ -50,8 +48,6 @@ class DriftLanding extends Page
public ?int $operationRunId = null;
public ?int $bulkOperationRunId = null;
/** @var array<string, int>|null */
public ?array $statusCounts = null;
@ -118,17 +114,6 @@ public function mount(): void
$this->operationRunId = (int) $existingOperationRun->getKey();
}
$idempotencyKey = RunIdempotency::buildKey(
tenantId: (int) $tenant->getKey(),
operationType: 'drift.generate',
targetId: $scopeKey,
context: [
'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(),
],
);
$exists = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
@ -153,48 +138,39 @@ public function mount(): void
return;
}
$latestRun = BulkOperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('idempotency_key', $idempotencyKey)
->latest('id')
->first();
$existingOperationRun?->refresh();
$activeRun = RunIdempotency::findActiveBulkOperationRun((int) $tenant->getKey(), $idempotencyKey);
if ($activeRun instanceof BulkOperationRun) {
if ($existingOperationRun instanceof OperationRun
&& in_array($existingOperationRun->status, ['queued', 'running'], true)
) {
$this->state = 'generating';
$this->bulkOperationRunId = (int) $activeRun->getKey();
$this->operationRunId = (int) $existingOperationRun->getKey();
return;
}
if ($latestRun instanceof BulkOperationRun && $latestRun->status === 'completed') {
$this->state = 'ready';
$this->bulkOperationRunId = (int) $latestRun->getKey();
if ($existingOperationRun instanceof OperationRun
&& $existingOperationRun->status === 'completed'
) {
$counts = is_array($existingOperationRun->summary_counts ?? null) ? $existingOperationRun->summary_counts : [];
$created = (int) ($counts['created'] ?? 0);
$newCount = (int) Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('scope_key', $scopeKey)
->where('baseline_run_id', $baseline->getKey())
->where('current_run_id', $current->getKey())
->where('status', Finding::STATUS_NEW)
->count();
if ($existingOperationRun->outcome === 'failed') {
$this->state = 'error';
$this->message = 'Drift generation failed for this comparison. See the run for details.';
$this->operationRunId = (int) $existingOperationRun->getKey();
$this->statusCounts = [Finding::STATUS_NEW => $newCount];
if ($newCount === 0) {
$this->message = 'No drift findings for this comparison. If you changed settings after the current run, run Inventory Sync again to capture a newer snapshot.';
return;
}
return;
}
if ($created === 0) {
$this->state = 'ready';
$this->statusCounts = [Finding::STATUS_NEW => 0];
$this->message = 'No drift findings for this comparison. If you changed settings after the current run, run Inventory Sync again to capture a newer snapshot.';
$this->operationRunId = (int) $existingOperationRun->getKey();
if ($latestRun instanceof BulkOperationRun && in_array($latestRun->status, ['failed', 'aborted'], true)) {
$this->state = 'error';
$this->message = 'Drift generation failed for this comparison. See the run for details.';
$this->bulkOperationRunId = (int) $latestRun->getKey();
return;
return;
}
}
if (! $user->canSyncTenant($tenant)) {
@ -204,73 +180,60 @@ public function mount(): void
return;
}
// --- Phase 3: Canonical Operation Run Start ---
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromQuery([
'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(),
]);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
$opRun = $opService->enqueueBulkOperation(
tenant: $tenant,
type: 'drift.generate',
inputs: [
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $user, $baseline, $current, $scopeKey): void {
GenerateDriftFindingsJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
baselineRunId: (int) $baseline->getKey(),
currentRunId: (int) $current->getKey(),
scopeKey: $scopeKey,
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(),
],
initiator: $user
emitQueuedNotification: false,
);
$this->operationRunId = (int) $opRun->getKey();
$this->state = 'generating';
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
$this->state = 'generating'; // Reflect generating state in UI if idempotency hit
// Optionally, we could find the related BulkOpRun to link, but the UI might just need state.
if (! $opRun->wasRecentlyCreated) {
Notification::make()
->title('Drift generation already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
// ----------------------------------------------
$bulkOperationService = app(BulkOperationService::class);
$run = $bulkOperationService->createRun(
tenant: $tenant,
user: $user,
resource: 'drift',
action: 'generate',
itemIds: [
'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(),
],
totalItems: 1,
);
$run->update(['idempotency_key' => $idempotencyKey]);
$this->state = 'generating';
$this->bulkOperationRunId = (int) $run->getKey();
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $baseline, $current, $scopeKey, $run, $opRun): void {
GenerateDriftFindingsJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
baselineRunId: (int) $baseline->getKey(),
currentRunId: (int) $current->getKey(),
scopeKey: $scopeKey,
bulkOperationRunId: (int) $run->getKey(),
operationRun: $opRun
);
});
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)

View File

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

View File

@ -13,7 +13,6 @@
use App\Rules\SupportedPolicyTypesRule;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
@ -397,23 +396,8 @@ public static function table(Table $table): Table
],
);
$bulkRunId = null;
if ($userModel instanceof User) {
$bulkRunId = app(BulkOperationService::class)
->createRun(
tenant: $tenant,
user: $userModel,
resource: 'backup_schedule',
action: 'run',
itemIds: [(string) $record->id],
totalItems: 1,
)
->id;
}
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRunId, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId, $operationRun));
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
});
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
@ -536,23 +520,8 @@ public static function table(Table $table): Table
],
);
$bulkRunId = null;
if ($userModel instanceof User) {
$bulkRunId = app(BulkOperationService::class)
->createRun(
tenant: $tenant,
user: $userModel,
resource: 'backup_schedule',
action: 'retry',
itemIds: [(string) $record->id],
totalItems: 1,
)
->id;
}
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRunId, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId, $operationRun));
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
});
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
@ -591,16 +560,6 @@ public static function table(Table $table): Table
$operationRunService = app(OperationRunService::class);
$bulkRun = null;
if ($user) {
$bulkRun = app(\App\Services\BulkOperationService::class)->createRun(
tenant: $tenant,
user: $user,
resource: 'backup_schedule',
action: 'run',
itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(),
totalItems: $records->count(),
);
}
$createdRunIds = [];
@ -678,13 +637,12 @@ public static function table(Table $table): Table
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'bulk_run_now',
'bulk_run_id' => $bulkRun?->id,
],
],
);
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun));
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
}, emitQueuedNotification: false);
}
@ -731,16 +689,6 @@ public static function table(Table $table): Table
$operationRunService = app(OperationRunService::class);
$bulkRun = null;
if ($user) {
$bulkRun = app(\App\Services\BulkOperationService::class)->createRun(
tenant: $tenant,
user: $user,
resource: 'backup_schedule',
action: 'retry',
itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(),
totalItems: $records->count(),
);
}
$createdRunIds = [];
@ -818,13 +766,12 @@ public static function table(Table $table): Table
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'bulk_retry',
'bulk_run_id' => $bulkRun?->id,
],
],
);
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun));
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
}, emitQueuedNotification: false);
}

View File

@ -9,9 +9,12 @@
use App\Jobs\BulkBackupSetRestoreJob;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Models\User;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\BackupService;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use BackedEnum;
use Filament\Actions;
@ -198,22 +201,48 @@ public static function table(Table $table): Table
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'backup_set', 'delete', $ids, $count);
if ($count >= 10) {
OperationUxPresenter::queuedToast('backup_set.delete')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
])
->send();
BulkBackupSetDeleteJob::dispatch($run->id);
} else {
BulkBackupSetDeleteJob::dispatchSync($run->id);
if (! $tenant instanceof Tenant) {
return;
}
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkBackupSetDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
backupSetIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'backup_set_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('backup_set.delete')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
@ -238,22 +267,48 @@ public static function table(Table $table): Table
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'backup_set', 'restore', $ids, $count);
if ($count >= 10) {
OperationUxPresenter::queuedToast('backup_set.restore')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
])
->send();
BulkBackupSetRestoreJob::dispatch($run->id);
} else {
BulkBackupSetRestoreJob::dispatchSync($run->id);
if (! $tenant instanceof Tenant) {
return;
}
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.restore',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkBackupSetRestoreJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
backupSetIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'backup_set_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('backup_set.restore')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
@ -293,22 +348,48 @@ public static function table(Table $table): Table
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'backup_set', 'force_delete', $ids, $count);
if ($count >= 10) {
OperationUxPresenter::queuedToast('backup_set.force_delete')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
])
->send();
BulkBackupSetForceDeleteJob::dispatch($run->id);
} else {
BulkBackupSetForceDeleteJob::dispatchSync($run->id);
if (! $tenant instanceof Tenant) {
return;
}
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.force_delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkBackupSetForceDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
backupSetIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'backup_set_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('backup_set.force_delete')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
]),

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

@ -11,9 +11,9 @@
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Intune\PolicyNormalizer;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
@ -458,10 +458,51 @@ public static function table(Table $table): Table
$tenant = Tenant::current();
$user = auth()->user();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'export', [$record->id], 1);
if (! $user instanceof User) {
abort(403);
}
BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']);
$ids = [(int) $record->getKey()];
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy.export',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data): void {
BulkPolicyExportJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $ids,
backupName: (string) $data['backup_name'],
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'backup_name' => (string) $data['backup_name'],
'policy_count' => 1,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
}),
])->icon('heroicon-o-ellipsis-vertical'),
])
@ -499,24 +540,54 @@ public static function table(Table $table): Table
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'delete', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk delete started')
->body("Deleting {$count} policies in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyDeleteJob::dispatch($run->id);
} else {
BulkPolicyDeleteJob::dispatchSync($run->id);
if (! $user instanceof User) {
return;
}
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy.delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $user, $ids): void {
BulkPolicyDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $ids,
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'policy_count' => $count,
],
emitQueuedNotification: false,
);
Notification::make()
->title('Policy delete queued')
->body("Queued deletion for {$count} policies.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->duration(8000)
->sendToDatabase($user)
->send();
})
->deselectRecordsAfterCompletion(),
@ -537,8 +608,50 @@ public static function table(Table $table): Table
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'unignore', $ids, $count);
if (! $user instanceof User) {
abort(403);
}
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy.unignore',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void {
if ($count >= 20) {
BulkPolicyUnignoreJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $ids,
operationRun: $operationRun,
);
return;
}
BulkPolicyUnignoreJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $ids,
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'policy_count' => $count,
],
emitQueuedNotification: false,
);
if ($count >= 20) {
Notification::make()
@ -550,11 +663,17 @@ public static function table(Table $table): Table
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyUnignoreJob::dispatch($run->id);
} else {
BulkPolicyUnignoreJob::dispatchSync($run->id);
}
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
@ -660,8 +779,53 @@ public static function table(Table $table): Table
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'export', $ids, $count);
if (! $user instanceof User) {
abort(403);
}
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy.export',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void {
if ($count >= 20) {
BulkPolicyExportJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $ids,
backupName: (string) $data['backup_name'],
operationRun: $operationRun,
);
return;
}
BulkPolicyExportJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $ids,
backupName: (string) $data['backup_name'],
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'backup_name' => (string) $data['backup_name'],
'policy_count' => $count,
],
emitQueuedNotification: false,
);
if ($count >= 20) {
Notification::make()
@ -673,11 +837,15 @@ public static function table(Table $table): Table
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyExportJob::dispatch($run->id, $data['backup_name']);
} else {
BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']);
}
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
]),

View File

@ -2,12 +2,12 @@
namespace App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\BulkOperationRunResource;
use App\Filament\Resources\PolicyResource;
use App\Jobs\CapturePolicySnapshotJob;
use App\Services\BulkOperationService;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\RunIdempotency;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
@ -54,64 +54,67 @@ protected function getActions(): array
return;
}
$idempotencyKey = RunIdempotency::buildKey(
tenantId: $tenant->getKey(),
operationType: 'policy.capture_snapshot',
targetId: $policy->getKey()
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds([(string) $policy->getKey()]);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy.capture_snapshot',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $policy, $user, $data): void {
CapturePolicySnapshotJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyId: (int) $policy->getKey(),
includeAssignments: (bool) ($data['include_assignments'] ?? false),
includeScopeTags: (bool) ($data['include_scope_tags'] ?? false),
createdBy: $user->email ? Str::limit($user->email, 255, '') : null,
operationRun: $operationRun,
context: [],
);
},
initiator: $user,
extraContext: [
'policy_id' => (int) $policy->getKey(),
'include_assignments' => (bool) ($data['include_assignments'] ?? false),
'include_scope_tags' => (bool) ($data['include_scope_tags'] ?? false),
],
emitQueuedNotification: false,
);
$existingRun = RunIdempotency::findActiveBulkOperationRun(
tenantId: $tenant->getKey(),
idempotencyKey: $idempotencyKey
);
if ($existingRun) {
if (! $opRun->wasRecentlyCreated) {
Notification::make()
->title('Snapshot already in progress')
->body('An active run already exists for this policy. Opening run details.')
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)),
->url(OperationRunLinks::view($opRun, $tenant)),
])
->info()
->send();
$this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant));
$this->redirect(OperationRunLinks::view($opRun, $tenant));
return;
}
$bulkOperationService = app(BulkOperationService::class);
$run = $bulkOperationService->createRun(
tenant: $tenant,
user: $user,
resource: 'policies',
action: 'capture_snapshot',
itemIds: [(string) $policy->getKey()],
totalItems: 1
);
$run->update(['idempotency_key' => $idempotencyKey]);
CapturePolicySnapshotJob::dispatch(
bulkOperationRunId: $run->getKey(),
policyId: $policy->getKey(),
includeAssignments: (bool) ($data['include_assignments'] ?? false),
includeScopeTags: (bool) ($data['include_scope_tags'] ?? false),
createdBy: $user->email ? Str::limit($user->email, 255, '') : null
);
OperationUxPresenter::queuedToast('policy.capture_snapshot')
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
$this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant));
$this->redirect(OperationRunLinks::view($opRun, $tenant));
})
->color('primary'),
];

View File

@ -10,10 +10,13 @@
use App\Models\BackupSet;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Models\User;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\PolicyNormalizer;
use App\Services\Intune\VersionDiff;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use BackedEnum;
use Carbon\CarbonImmutable;
@ -406,21 +409,58 @@ public static function table(Table $table): Table
$retentionDays = (int) ($data['retention_days'] ?? 90);
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'prune', $ids, $count);
if (! $tenant instanceof Tenant) {
return;
}
if ($count >= 20) {
OperationUxPresenter::queuedToast('policy_version.prune')
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy_version.prune',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void {
BulkPolicyVersionPruneJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
policyVersionIds: $ids,
retentionDays: $retentionDays,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'policy_version_count' => $count,
'retention_days' => $retentionDays,
],
emitQueuedNotification: false,
);
if ($initiator instanceof User) {
Notification::make()
->title('Policy version prune queued')
->body("Queued prune for {$count} policy versions.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
->url(OperationRunLinks::view($opRun, $tenant)),
])
->duration(8000)
->sendToDatabase($initiator)
->send();
BulkPolicyVersionPruneJob::dispatch($run->id, $retentionDays);
} else {
BulkPolicyVersionPruneJob::dispatchSync($run->id, $retentionDays);
}
})
->deselectRecordsAfterCompletion(),
@ -446,22 +486,48 @@ public static function table(Table $table): Table
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'restore', $ids, $count);
if ($count >= 20) {
OperationUxPresenter::queuedToast('policy_version.restore')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
])
->send();
BulkPolicyVersionRestoreJob::dispatch($run->id);
} else {
BulkPolicyVersionRestoreJob::dispatchSync($run->id);
if (! $tenant instanceof Tenant) {
return;
}
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy_version.restore',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkPolicyVersionRestoreJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
policyVersionIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'policy_version_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('policy_version.restore')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
@ -495,21 +561,56 @@ public static function table(Table $table): Table
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', $ids, $count);
if (! $tenant instanceof Tenant) {
return;
}
if ($count >= 20) {
OperationUxPresenter::queuedToast('policy_version.force_delete')
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'policy_version.force_delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkPolicyVersionForceDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
policyVersionIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'policy_version_count' => $count,
],
emitQueuedNotification: false,
);
if ($initiator instanceof User) {
Notification::make()
->title('Policy version force delete queued')
->body("Queued force delete for {$count} policy versions.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
->url(OperationRunLinks::view($opRun, $tenant)),
])
->duration(8000)
->sendToDatabase($initiator)
->send();
BulkPolicyVersionForceDeleteJob::dispatch($run->id);
} else {
BulkPolicyVersionForceDeleteJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),

View File

@ -12,17 +12,20 @@
use App\Models\EntraGroup;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Rules\SkipOrUuidRule;
use App\Services\BulkOperationService;
use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreDiffGenerator;
use App\Services\Intune\RestoreRiskChecker;
use App\Services\Intune\RestoreService;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\RestoreRunIdempotency;
use App\Support\RestoreRunStatus;
use App\Support\RunIdempotency;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
@ -780,14 +783,14 @@ public static function table(Table $table): Table
'rerun_of_restore_run_id' => $record->id,
];
$idempotencyKey = RunIdempotency::restoreExecuteKey(
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(),
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
Notification::make()
@ -813,7 +816,7 @@ public static function table(Table $table): Table
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
} catch (QueryException $exception) {
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
Notification::make()
@ -1032,24 +1035,48 @@ public static function table(Table $table): Table
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'restore_run', 'delete', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk delete started')
->body("Deleting {$count} restore runs in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkRestoreRunDeleteJob::dispatch($run->id);
} else {
BulkRestoreRunDeleteJob::dispatchSync($run->id);
if (! $tenant instanceof Tenant) {
return;
}
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'restore_run.delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkRestoreRunDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
restoreRunIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'restore_run_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('restore_run.delete')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
@ -1074,24 +1101,59 @@ public static function table(Table $table): Table
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'restore_run', 'restore', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk restore started')
->body("Restoring {$count} restore runs in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkRestoreRunRestoreJob::dispatch($run->id);
} else {
BulkRestoreRunRestoreJob::dispatchSync($run->id);
if (! $tenant instanceof Tenant) {
return;
}
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'restore_run.restore',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void {
if ($count >= 20) {
BulkRestoreRunRestoreJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
restoreRunIds: $ids,
operationRun: $operationRun,
);
return;
}
BulkRestoreRunRestoreJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
restoreRunIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'restore_run_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('restore_run.restore')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
@ -1125,24 +1187,59 @@ public static function table(Table $table): Table
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'restore_run', 'force_delete', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk force delete started')
->body("Force deleting {$count} restore runs in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkRestoreRunForceDeleteJob::dispatch($run->id);
} else {
BulkRestoreRunForceDeleteJob::dispatchSync($run->id);
if (! $tenant instanceof Tenant) {
return;
}
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'restore_run.force_delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void {
if ($count >= 20) {
BulkRestoreRunForceDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
restoreRunIds: $ids,
operationRun: $operationRun,
);
return;
}
BulkRestoreRunForceDeleteJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
restoreRunIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'restore_run_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('restore_run.force_delete')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
]),
@ -1484,14 +1581,14 @@ public static function createRestoreRun(array $data): RestoreRun
$metadata['preview_ran_at'] = $previewRanAt;
}
$idempotencyKey = RunIdempotency::restoreExecuteKey(
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(),
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
Notification::make()
@ -1517,7 +1614,7 @@ public static function createRestoreRun(array $data): RestoreRun
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
} catch (QueryException $exception) {
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
Notification::make()

View File

@ -8,7 +8,6 @@
use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Graph\GraphClientInterface;
use App\Services\Intune\AuditLogger;
@ -17,6 +16,7 @@
use App\Services\Intune\TenantConfigService;
use App\Services\Intune\TenantPermissionService;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
@ -448,49 +448,42 @@ public static function table(Table $table): Table
$ids = $eligible->pluck('id')->toArray();
$count = $eligible->count();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenantContext, $user, 'tenant', 'sync', $ids, $count);
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
foreach ($eligible as $tenant) {
// Note: We might want canonical runs for bulk syncs too, but spec Phase 1 mentions tenant-scoped operations.
// Bulk operation across tenants is a higher level concept.
// Keeping it as is for now or migrating individually.
// If we want each tenant sync to show in its Monitoring, we should create opRun for each.
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: ['scope' => 'full', 'bulk_run_id' => $run->id],
initiator: $user
);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenantContext,
type: 'tenant.sync',
targetScope: [
'entra_tenant_id' => (string) ($tenantContext->tenant_id ?? $tenantContext->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenantContext, $user, $ids): void {
BulkTenantSyncJob::dispatch(
tenantId: (int) $tenantContext->getKey(),
userId: (int) $user->getKey(),
tenantIds: $ids,
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'tenant_count' => $count,
],
emitQueuedNotification: false,
);
SyncPoliciesJob::dispatch($tenant->getKey(), null, null, $opRun);
$auditLogger->log(
tenant: $tenant,
action: 'tenant.sync_dispatched',
resourceType: 'tenant',
resourceId: (string) $tenant->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $tenant->tenant_id]],
);
}
$count = $eligible->count();
Notification::make()
->title('Bulk sync started')
->body("Syncing {$count} tenant(s) in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->success()
->duration(8000)
->sendToDatabase($user)
OperationUxPresenter::queuedToast('tenant.sync')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenantContext)),
])
->send();
BulkTenantSyncJob::dispatch($run->id);
})
->deselectRecordsAfterCompletion(),
])

View File

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

View File

@ -2,149 +2,93 @@
namespace App\Jobs;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use App\Jobs\Operations\BackupSetDeleteWorkerJob;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
use RuntimeException;
class BulkBackupSetDeleteJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $backupSetIds
* @param array<string, mixed> $context
*/
public function __construct(
public int $bulkRunId,
) {}
public int $tenantId,
public int $userId,
public array $backupSetIds,
?OperationRun $operationRun = null,
public array $context = [],
) {
$this->operationRun = $operationRun;
}
public function handle(BulkOperationService $service): void
public function handle(OperationRunService $runs): void
{
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
if (! $this->operationRun instanceof OperationRun) {
throw new RuntimeException('OperationRun is required for BulkBackupSetDeleteJob.');
}
if (! $run || $run->status !== 'pending') {
$this->operationRun->refresh();
if ($this->operationRun->status === 'completed') {
return;
}
$service->start($run);
$runs->updateRun($this->operationRun, 'running');
$itemCount = 0;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$ids = $this->normalizeIds($this->backupSetIds);
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
foreach (($run->item_ids ?? []) as $backupSetId) {
$itemCount++;
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$chunkSize = max(1, $chunkSize);
try {
/** @var BackupSet|null $backupSet */
$backupSet = BackupSet::withTrashed()
->where('tenant_id', $run->tenant_id)
->whereKey($backupSetId)
->first();
foreach (array_chunk($ids, $chunkSize) as $chunk) {
foreach ($chunk as $backupSetId) {
dispatch(new BackupSetDeleteWorkerJob(
tenantId: $this->tenantId,
userId: $this->userId,
backupSetId: $backupSetId,
operationRun: $this->operationRun,
context: $this->context,
));
}
}
}
if (! $backupSet) {
$service->recordFailure($run, (string) $backupSetId, 'Backup set not found');
$failed++;
/**
* @param array<int, mixed> $ids
* @return array<int, int>
*/
private function normalizeIds(array $ids): array
{
$normalized = [];
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
foreach ($ids as $id) {
if (is_int($id)) {
$normalized[] = $id;
if ($run->user) {
Notification::make()
->title('Bulk Archive Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
continue;
}
if ($backupSet->trashed()) {
$service->recordSkippedWithReason($run, (string) $backupSet->id, 'Already archived');
$skipped++;
$skipReasons['Already archived'] = ($skipReasons['Already archived'] ?? 0) + 1;
continue;
}
$backupSet->delete();
$service->recordSuccess($run);
$succeeded++;
} catch (Throwable $e) {
$service->recordFailure($run, (string) $backupSetId, $e->getMessage());
$failed++;
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Archive Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
continue;
}
if ($itemCount % $chunkSize === 0) {
$run->refresh();
if (is_numeric($id)) {
$normalized[] = (int) $id;
}
}
$service->complete($run);
$normalized = array_values(array_unique($normalized));
sort($normalized);
if (! $run->user) {
return;
}
$message = "Archived {$succeeded} backup sets";
if ($skipped > 0) {
$message .= " ({$skipped} skipped)";
}
if ($failed > 0) {
$message .= " ({$failed} failed)";
}
if (! empty($skipReasons)) {
$summary = collect($skipReasons)
->sortDesc()
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
->take(3)
->implode(', ');
if ($summary !== '') {
$message .= " Skip reasons: {$summary}.";
}
}
$message .= '.';
Notification::make()
->title('Bulk Archive Completed')
->body($message)
->icon('heroicon-o-check-circle')
->success()
->sendToDatabase($run->user)
->send();
return $normalized;
}
}

View File

@ -2,159 +2,93 @@
namespace App\Jobs;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use App\Jobs\Operations\BackupSetForceDeleteWorkerJob;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
use RuntimeException;
class BulkBackupSetForceDeleteJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $backupSetIds
* @param array<string, mixed> $context
*/
public function __construct(
public int $bulkRunId,
) {}
public int $tenantId,
public int $userId,
public array $backupSetIds,
?OperationRun $operationRun = null,
public array $context = [],
) {
$this->operationRun = $operationRun;
}
public function handle(BulkOperationService $service): void
public function handle(OperationRunService $runs): void
{
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
if (! $this->operationRun instanceof OperationRun) {
throw new RuntimeException('OperationRun is required for BulkBackupSetForceDeleteJob.');
}
if (! $run || $run->status !== 'pending') {
$this->operationRun->refresh();
if ($this->operationRun->status === 'completed') {
return;
}
$service->start($run);
$runs->updateRun($this->operationRun, 'running');
$itemCount = 0;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$ids = $this->normalizeIds($this->backupSetIds);
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
foreach (($run->item_ids ?? []) as $backupSetId) {
$itemCount++;
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$chunkSize = max(1, $chunkSize);
try {
/** @var BackupSet|null $backupSet */
$backupSet = BackupSet::withTrashed()
->where('tenant_id', $run->tenant_id)
->whereKey($backupSetId)
->first();
foreach (array_chunk($ids, $chunkSize) as $chunk) {
foreach ($chunk as $backupSetId) {
dispatch(new BackupSetForceDeleteWorkerJob(
tenantId: $this->tenantId,
userId: $this->userId,
backupSetId: $backupSetId,
operationRun: $this->operationRun,
context: $this->context,
));
}
}
}
if (! $backupSet) {
$service->recordFailure($run, (string) $backupSetId, 'Backup set not found');
$failed++;
/**
* @param array<int, mixed> $ids
* @return array<int, int>
*/
private function normalizeIds(array $ids): array
{
$normalized = [];
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
foreach ($ids as $id) {
if (is_int($id)) {
$normalized[] = $id;
if ($run->user) {
Notification::make()
->title('Bulk Force Delete Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
continue;
}
if (! $backupSet->trashed()) {
$service->recordSkippedWithReason($run, (string) $backupSet->id, 'Not archived');
$skipped++;
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
continue;
}
if ($backupSet->restoreRuns()->withTrashed()->exists()) {
$service->recordSkippedWithReason($run, (string) $backupSet->id, 'Referenced by restore runs');
$skipped++;
$skipReasons['Referenced by restore runs'] = ($skipReasons['Referenced by restore runs'] ?? 0) + 1;
continue;
}
$backupSet->items()->withTrashed()->forceDelete();
$backupSet->forceDelete();
$service->recordSuccess($run);
$succeeded++;
} catch (Throwable $e) {
$service->recordFailure($run, (string) $backupSetId, $e->getMessage());
$failed++;
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Force Delete Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
continue;
}
if ($itemCount % $chunkSize === 0) {
$run->refresh();
if (is_numeric($id)) {
$normalized[] = (int) $id;
}
}
$service->complete($run);
$normalized = array_values(array_unique($normalized));
sort($normalized);
if (! $run->user) {
return;
}
$message = "Force deleted {$succeeded} backup sets";
if ($skipped > 0) {
$message .= " ({$skipped} skipped)";
}
if ($failed > 0) {
$message .= " ({$failed} failed)";
}
if (! empty($skipReasons)) {
$summary = collect($skipReasons)
->sortDesc()
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
->take(3)
->implode(', ');
if ($summary !== '') {
$message .= " Skip reasons: {$summary}.";
}
}
$message .= '.';
Notification::make()
->title('Bulk Force Delete Completed')
->body($message)
->icon('heroicon-o-check-circle')
->success()
->sendToDatabase($run->user)
->send();
return $normalized;
}
}

View File

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

View File

@ -2,184 +2,93 @@
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use App\Jobs\Operations\PolicyBulkDeleteWorkerJob;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
use RuntimeException;
class BulkPolicyDeleteJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $policyIds
* @param array<string, mixed> $context
*/
public function __construct(
public int $tenantId,
public int $userId,
public array $policyIds,
?OperationRun $operationRun = null,
public array $context = [],
) {
$this->operationRun = $operationRun;
}
public int $bulkRunId
) {}
public function handle(BulkOperationService $service): void
public function handle(OperationRunService $runs): void
{
if (! $this->operationRun instanceof OperationRun) {
throw new RuntimeException('OperationRun is required for BulkPolicyDeleteJob.');
}
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
if (! $run || $run->status !== 'pending') {
$this->operationRun->refresh();
if ($this->operationRun->status === 'completed') {
return;
}
$service->start($run);
$runs->updateRun($this->operationRun, 'running');
try {
$ids = $this->normalizeIds($this->policyIds);
$itemCount = 0;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$failures = [];
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$chunkSize = max(1, $chunkSize);
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
foreach (array_chunk($ids, $chunkSize) as $chunk) {
foreach ($chunk as $policyId) {
dispatch(new PolicyBulkDeleteWorkerJob(
tenantId: $this->tenantId,
userId: $this->userId,
policyId: $policyId,
operationRun: $this->operationRun,
context: $this->context,
));
}
}
}
foreach ($run->item_ids as $policyId) {
/**
* @param array<int, mixed> $ids
* @return array<int, int>
*/
private function normalizeIds(array $ids): array
{
$normalized = [];
$itemCount++;
try {
$policy = Policy::find($policyId);
if (! $policy) {
$service->recordFailure($run, (string) $policyId, 'Policy not found');
$failed++;
$failures[] = [
'item_id' => (string) $policyId,
'reason' => 'Policy not found',
'timestamp' => now()->toIso8601String(),
];
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Delete Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
continue;
}
if ($policy->ignored_at) {
$service->recordSkipped($run);
$skipped++;
continue;
}
$policy->ignore();
$service->recordSuccess($run);
$succeeded++;
} catch (Throwable $e) {
$service->recordFailure($run, (string) $policyId, $e->getMessage());
$failed++;
$failures[] = [
'item_id' => (string) $policyId,
'reason' => $e->getMessage(),
'timestamp' => now()->toIso8601String(),
];
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Delete Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
}
// Refresh the run from database every $chunkSize items to avoid stale data
if ($itemCount % $chunkSize === 0) {
$run->refresh();
}
foreach ($ids as $id) {
if (is_int($id)) {
$normalized[] = $id;
continue;
}
$service->complete($run);
if ($succeeded > 0 || $failed > 0 || $skipped > 0) {
$message = "Successfully deleted {$succeeded} policies";
if ($skipped > 0) {
$message .= " ({$skipped} skipped)";
}
if ($failed > 0) {
$message .= " ({$failed} failed)";
}
$message .= '.';
Notification::make()
->title('Bulk Delete Completed')
->body($message)
->icon('heroicon-o-check-circle')
->success()
->sendToDatabase($run->user)
->send();
if (is_numeric($id)) {
$normalized[] = (int) $id;
}
} catch (Throwable $e) {
$service->fail($run, $e->getMessage());
// Reload run with user relationship
$run->refresh();
$run->load('user');
if ($run->user) {
Notification::make()
->title('Bulk Delete Failed')
->body($e->getMessage())
->icon('heroicon-o-x-circle')
->danger()
->sendToDatabase($run->user)
->send();
}
throw $e;
}
$normalized = array_values(array_unique($normalized));
sort($normalized);
return $normalized;
}
}

View File

@ -2,11 +2,17 @@
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Services\BulkOperationService;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -19,31 +25,53 @@ class BulkPolicyExportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $bulkRunId,
public int $tenantId,
public int $userId,
/** @var array<int, int|string> */
public array $policyIds,
public string $backupName,
public ?string $backupDescription = null
) {}
public ?string $backupDescription = null,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
public function handle(BulkOperationService $service): void
public function middleware(): array
{
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
return [new TrackOperationRun];
}
if (! $run || $run->status !== 'pending') {
return;
public function handle(OperationRunService $operationRunService): void
{
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new \RuntimeException('Tenant not found.');
}
$service->start($run);
$user = User::query()->find($this->userId);
if (! $user instanceof User) {
throw new \RuntimeException('User not found.');
}
$ids = collect($this->policyIds)
->map(static fn ($id): int => (int) $id)
->unique()
->sort()
->values()
->all();
try {
// Create Backup Set
$backupSet = BackupSet::create([
'tenant_id' => $run->tenant_id,
'tenant_id' => $tenant->getKey(),
'name' => $this->backupName,
// 'description' => $this->backupDescription, // Not in schema
'status' => 'completed',
'created_by' => $run->user?->name ?? (string) $run->user_id, // Schema has created_by string
'item_count' => count($run->item_ids),
'created_by' => $user->name,
'item_count' => count($ids),
'completed_at' => now(),
]);
@ -51,37 +79,55 @@ public function handle(BulkOperationService $service): void
$succeeded = 0;
$failed = 0;
$failures = [];
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$totalItems = count($ids);
$failureThreshold = (int) floor($totalItems / 2);
foreach ($run->item_ids as $policyId) {
foreach ($ids as $policyId) {
$itemCount++;
try {
$policy = Policy::find($policyId);
$policy = Policy::query()
->where('tenant_id', $tenant->getKey())
->find($policyId);
if (! $policy) {
$service->recordFailure($run, (string) $policyId, 'Policy not found');
$failed++;
$failures[] = [
'item_id' => (string) $policyId,
'reason' => 'Policy not found',
'timestamp' => now()->toIso8601String(),
];
$failures[] = ['code' => 'policy.not_found', 'message' => "Policy {$policyId} not found."];
if ($failed > $failureThreshold) {
$backupSet->update(['status' => 'failed']);
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
summaryCounts: [
'total' => $totalItems,
'processed' => $itemCount,
'succeeded' => $succeeded,
'failed' => $failed,
'created' => $succeeded,
],
failures: array_merge($failures, [
['code' => 'export.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
]),
);
}
if ($user) {
Notification::make()
->title('Bulk Export Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
@ -95,25 +141,42 @@ public function handle(BulkOperationService $service): void
$latestVersion = $policy->versions()->orderByDesc('captured_at')->first();
if (! $latestVersion) {
$service->recordFailure($run, (string) $policyId, 'No versions available for policy');
$failed++;
$failures[] = [
'item_id' => (string) $policyId,
'reason' => 'No versions available for policy',
'timestamp' => now()->toIso8601String(),
];
$failures[] = ['code' => 'policy.no_versions', 'message' => "No versions available for policy {$policyId}."];
if ($failed > $failureThreshold) {
$backupSet->update(['status' => 'failed']);
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
summaryCounts: [
'total' => $totalItems,
'processed' => $itemCount,
'succeeded' => $succeeded,
'failed' => $failed,
'created' => $succeeded,
],
failures: array_merge($failures, [
['code' => 'export.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
]),
);
}
if ($user) {
Notification::make()
->title('Bulk Export Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
@ -125,7 +188,7 @@ public function handle(BulkOperationService $service): void
// Create Backup Item
BackupItem::create([
'tenant_id' => $run->tenant_id,
'tenant_id' => $tenant->getKey(),
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id, // Added
@ -139,46 +202,81 @@ public function handle(BulkOperationService $service): void
],
]);
$service->recordSuccess($run);
$succeeded++;
} catch (Throwable $e) {
$service->recordFailure($run, (string) $policyId, $e->getMessage());
$failed++;
$failures[] = [
'item_id' => (string) $policyId,
'reason' => $e->getMessage(),
'timestamp' => now()->toIso8601String(),
];
$failures[] = ['code' => 'policy.export.failed', 'message' => $e->getMessage()];
if ($failed > $failureThreshold) {
$backupSet->update(['status' => 'failed']);
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
summaryCounts: [
'total' => $totalItems,
'processed' => $itemCount,
'succeeded' => $succeeded,
'failed' => $failed,
'created' => $succeeded,
],
failures: array_merge($failures, [
['code' => 'export.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
]),
);
}
if ($user) {
Notification::make()
->title('Bulk Export Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
return;
}
}
// Refresh the run from database every 10 items to avoid stale data
if ($itemCount % $chunkSize === 0) {
$run->refresh();
}
}
// Update BackupSet item count (if denormalized) or just leave it
// Assuming BackupSet might need an item count or status update
$service->complete($run);
$outcome = OperationRunOutcome::Succeeded->value;
if ($failed > 0 && $failed < $totalItems) {
$outcome = OperationRunOutcome::PartiallySucceeded->value;
}
if ($failed >= $totalItems && $totalItems > 0) {
$outcome = OperationRunOutcome::Failed->value;
}
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: $outcome,
summaryCounts: [
'total' => $totalItems,
'processed' => $totalItems,
'succeeded' => $succeeded,
'failed' => $failed,
'created' => $succeeded,
],
failures: $failures,
);
}
if ($succeeded > 0 || $failed > 0) {
$message = "Successfully exported {$succeeded} policies to backup '{$this->backupName}'";
@ -192,24 +290,39 @@ public function handle(BulkOperationService $service): void
->body($message)
->icon('heroicon-o-check-circle')
->success()
->sendToDatabase($run->user)
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
} catch (Throwable $e) {
$service->fail($run, $e->getMessage());
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [
['code' => 'exception.unhandled', 'message' => $e->getMessage()],
],
);
}
// Reload run with user relationship
$run->refresh();
$run->load('user');
if ($run->user) {
if (isset($user) && $user instanceof User) {
Notification::make()
->title('Bulk Export Failed')
->body($e->getMessage())
->icon('heroicon-o-x-circle')
->danger()
->sendToDatabase($run->user)
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}

View File

@ -2,17 +2,14 @@
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Services\BulkOperationService;
use App\Models\OperationRun;
use App\Services\Intune\PolicySyncService;
use Filament\Notifications\Notification;
use App\Services\OperationRunService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class BulkPolicySyncJob implements ShouldQueue
{
@ -20,128 +17,24 @@ class BulkPolicySyncJob implements ShouldQueue
public function __construct(public int $bulkRunId) {}
public function handle(BulkOperationService $service, PolicySyncService $syncService): void
public function handle(PolicySyncService $syncService, OperationRunService $runs): void
{
$run = BulkOperationRun::with(['tenant', 'user'])->find($this->bulkRunId);
$run = OperationRun::query()->find($this->bulkRunId);
if (! $run || $run->status !== 'pending') {
if (! $run instanceof OperationRun) {
return;
}
$policyIds = data_get($run->context ?? [], 'policy_ids');
if (! is_array($policyIds)) {
return;
}
$service->start($run);
try {
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$itemCount = 0;
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
foreach (($run->item_ids ?? []) as $policyId) {
$itemCount++;
try {
$policy = Policy::query()
->whereKey($policyId)
->where('tenant_id', $run->tenant_id)
->first();
if (! $policy) {
$service->recordFailure($run, (string) $policyId, 'Policy not found');
if ($run->failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Sync Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
continue;
}
if ($policy->ignored_at) {
$service->recordSkippedWithReason($run, (string) $policyId, 'Policy is ignored locally');
continue;
}
$syncService->syncPolicy($run->tenant, $policy);
$service->recordSuccess($run);
} catch (Throwable $e) {
$service->recordFailure($run, (string) $policyId, $e->getMessage());
if ($run->failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Sync Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
}
if ($itemCount % $chunkSize === 0) {
$run->refresh();
}
}
$service->complete($run);
if ($run->user) {
$message = "Synced {$run->succeeded} policies";
if ($run->skipped > 0) {
$message .= " ({$run->skipped} skipped)";
}
if ($run->failed > 0) {
$message .= " ({$run->failed} failed)";
}
$message .= '.';
Notification::make()
->title('Bulk Sync Completed')
->body($message)
->icon('heroicon-o-check-circle')
->success()
->sendToDatabase($run->user)
->send();
}
} catch (Throwable $e) {
$service->fail($run, $e->getMessage());
$run->refresh();
$run->load('user');
if ($run->user) {
Notification::make()
->title('Bulk Sync Failed')
->body($e->getMessage())
->icon('heroicon-o-x-circle')
->danger()
->sendToDatabase($run->user)
->send();
}
throw $e;
}
(new SyncPoliciesJob(
tenantId: (int) $run->tenant_id,
types: null,
policyIds: $policyIds,
operationRun: $run,
))->handle($syncService, $runs);
}
}

View File

@ -2,9 +2,15 @@
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Services\BulkOperationService;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -17,63 +23,113 @@ class BulkPolicyUnignoreJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $bulkRunId) {}
public ?OperationRun $operationRun = null;
public function handle(BulkOperationService $service): void
/**
* @param array<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') {
return;
public function handle(OperationRunService $operationRunService): void
{
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new \RuntimeException('Tenant not found.');
}
$service->start($run);
$user = User::query()->find($this->userId);
if (! $user instanceof User) {
throw new \RuntimeException('User not found.');
}
try {
$itemCount = 0;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$failures = [];
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$ids = collect($this->policyIds)
->map(static fn ($id): int => (int) $id)
->unique()
->sort()
->values()
->all();
foreach ($run->item_ids as $policyId) {
$itemCount++;
foreach ($ids as $policyId) {
try {
$policy = Policy::find($policyId);
$policy = Policy::query()
->where('tenant_id', $tenant->getKey())
->find($policyId);
if (! $policy) {
$service->recordFailure($run, (string) $policyId, 'Policy not found');
$failed++;
$failures[] = [
'code' => 'policy.not_found',
'message' => "Policy {$policyId} not found.",
];
continue;
}
if (! $policy->ignored_at) {
$service->recordSkipped($run);
$skipped++;
continue;
}
$policy->unignore();
$service->recordSuccess($run);
$succeeded++;
} catch (Throwable $e) {
$service->recordFailure($run, (string) $policyId, $e->getMessage());
$failed++;
}
if ($itemCount % $chunkSize === 0) {
$run->refresh();
$failures[] = [
'code' => 'policy.unignore.failed',
'message' => $e->getMessage(),
];
}
}
$service->complete($run);
$total = count($ids);
if ($run->user) {
$outcome = OperationRunOutcome::Succeeded->value;
if ($failed > 0 && $failed < $total) {
$outcome = OperationRunOutcome::PartiallySucceeded->value;
}
if ($failed >= $total && $total > 0) {
$outcome = OperationRunOutcome::Failed->value;
}
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: $outcome,
summaryCounts: [
'total' => $total,
'processed' => $total,
'succeeded' => $succeeded,
'failed' => $failed,
'skipped' => $skipped,
],
failures: $failures,
);
}
if ($user) {
$message = "Restored {$succeeded} policies";
if ($skipped > 0) {
@ -91,22 +147,38 @@ public function handle(BulkOperationService $service): void
->body($message)
->icon('heroicon-o-check-circle')
->success()
->sendToDatabase($run->user)
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
} catch (Throwable $e) {
$service->fail($run, $e->getMessage());
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [
['code' => 'exception.unhandled', 'message' => $e->getMessage()],
],
);
}
$run->refresh();
$run->load('user');
if ($run->user) {
if (isset($user) && $user instanceof User) {
Notification::make()
->title('Bulk Restore Failed')
->body($e->getMessage())
->icon('heroicon-o-x-circle')
->danger()
->sendToDatabase($run->user)
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}

View File

@ -2,147 +2,93 @@
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\PolicyVersion;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use App\Jobs\Operations\PolicyVersionForceDeleteWorkerJob;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
use RuntimeException;
class BulkPolicyVersionForceDeleteJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $policyVersionIds
* @param array<string, mixed> $context
*/
public function __construct(
public int $bulkRunId,
) {}
public int $tenantId,
public int $userId,
public array $policyVersionIds,
?OperationRun $operationRun = null,
public array $context = [],
) {
$this->operationRun = $operationRun;
}
public function handle(BulkOperationService $service): void
public function handle(OperationRunService $runs): void
{
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
if (! $this->operationRun instanceof OperationRun) {
throw new RuntimeException('OperationRun is required for BulkPolicyVersionForceDeleteJob.');
}
if (! $run || $run->status !== 'pending') {
$this->operationRun->refresh();
if ($this->operationRun->status === 'completed') {
return;
}
$service->start($run);
$runs->updateRun($this->operationRun, 'running');
$itemCount = 0;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$ids = $this->normalizeIds($this->policyVersionIds);
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
foreach (($run->item_ids ?? []) as $versionId) {
$itemCount++;
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$chunkSize = max(1, $chunkSize);
try {
/** @var PolicyVersion|null $version */
$version = PolicyVersion::withTrashed()
->where('tenant_id', $run->tenant_id)
->whereKey($versionId)
->first();
if (! $version) {
$service->recordFailure($run, (string) $versionId, 'Policy version not found');
$failed++;
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Force Delete Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
continue;
}
if (! $version->trashed()) {
$service->recordSkippedWithReason($run, (string) $version->id, 'Not archived');
$skipped++;
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
continue;
}
$version->forceDelete();
$service->recordSuccess($run);
$succeeded++;
} catch (Throwable $e) {
$service->recordFailure($run, (string) $versionId, $e->getMessage());
$failed++;
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Force Delete Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
foreach (array_chunk($ids, $chunkSize) as $chunk) {
foreach ($chunk as $policyVersionId) {
dispatch(new PolicyVersionForceDeleteWorkerJob(
tenantId: $this->tenantId,
userId: $this->userId,
policyVersionId: $policyVersionId,
operationRun: $this->operationRun,
context: $this->context,
));
}
if ($itemCount % $chunkSize === 0) {
$run->refresh();
}
}
$service->complete($run);
if ($run->user) {
$message = "Force deleted {$succeeded} policy versions";
if ($skipped > 0) {
$message .= " ({$skipped} skipped)";
}
if ($failed > 0) {
$message .= " ({$failed} failed)";
}
if (! empty($skipReasons)) {
$summary = collect($skipReasons)
->sortDesc()
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
->take(3)
->implode(', ');
if ($summary !== '') {
$message .= " Skip reasons: {$summary}.";
}
}
$message .= '.';
Notification::make()
->title('Bulk Force Delete Completed')
->body($message)
->icon('heroicon-o-check-circle')
->success()
->sendToDatabase($run->user)
->send();
}
}
/**
* @param array<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;
use App\Models\BulkOperationRun;
use App\Models\PolicyVersion;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use App\Jobs\Operations\PolicyVersionPruneWorkerJob;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
use RuntimeException;
class BulkPolicyVersionPruneJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $policyVersionIds
* @param array<string, mixed> $context
*/
public function __construct(
public int $bulkRunId,
public int $tenantId,
public int $userId,
public array $policyVersionIds,
public int $retentionDays = 90,
) {}
?OperationRun $operationRun = null,
public array $context = [],
) {
$this->operationRun = $operationRun;
}
public function handle(BulkOperationService $service): void
public function handle(OperationRunService $runs): void
{
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
if (! $this->operationRun instanceof OperationRun) {
throw new RuntimeException('OperationRun is required for BulkPolicyVersionPruneJob.');
}
if (! $run || $run->status !== 'pending') {
$this->operationRun->refresh();
if ($this->operationRun->status === 'completed') {
return;
}
$service->start($run);
$runs->updateRun($this->operationRun, 'running');
$itemCount = 0;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$ids = $this->normalizeIds($this->policyVersionIds);
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
foreach (($run->item_ids ?? []) as $versionId) {
$itemCount++;
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$chunkSize = max(1, $chunkSize);
try {
/** @var PolicyVersion|null $version */
$version = PolicyVersion::withTrashed()
->where('tenant_id', $run->tenant_id)
->whereKey($versionId)
->first();
if (! $version) {
$service->recordFailure($run, (string) $versionId, 'Policy version not found');
$failed++;
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Prune Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
continue;
}
if ($version->trashed()) {
$service->recordSkippedWithReason($run, (string) $version->id, 'Already archived');
$skipped++;
$skipReasons['Already archived'] = ($skipReasons['Already archived'] ?? 0) + 1;
continue;
}
$eligible = PolicyVersion::query()
->where('tenant_id', $run->tenant_id)
->whereKey($version->id)
->pruneEligible($this->retentionDays)
->exists();
if (! $eligible) {
$capturedAt = $version->captured_at;
$isTooRecent = $capturedAt && $capturedAt->gte(now()->subDays($this->retentionDays));
$latestVersionNumber = PolicyVersion::query()
->where('tenant_id', $run->tenant_id)
->where('policy_id', $version->policy_id)
->whereNull('deleted_at')
->max('version_number');
$isCurrent = $latestVersionNumber !== null && (int) $version->version_number === (int) $latestVersionNumber;
$reason = $isCurrent
? 'Current version'
: ($isTooRecent ? 'Too recent' : 'Not eligible');
$service->recordSkippedWithReason($run, (string) $version->id, $reason);
$skipped++;
$skipReasons[$reason] = ($skipReasons[$reason] ?? 0) + 1;
continue;
}
$version->delete();
$service->recordSuccess($run);
$succeeded++;
} catch (Throwable $e) {
$service->recordFailure($run, (string) $versionId, $e->getMessage());
$failed++;
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Prune Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
foreach (array_chunk($ids, $chunkSize) as $chunk) {
foreach ($chunk as $policyVersionId) {
dispatch(new PolicyVersionPruneWorkerJob(
tenantId: $this->tenantId,
userId: $this->userId,
policyVersionId: $policyVersionId,
retentionDays: $this->retentionDays,
operationRun: $this->operationRun,
context: $this->context,
));
}
if ($itemCount % $chunkSize === 0) {
$run->refresh();
}
}
$service->complete($run);
if ($run->user) {
$message = "Pruned {$succeeded} policy versions";
if ($skipped > 0) {
$message .= " ({$skipped} skipped)";
}
if ($failed > 0) {
$message .= " ({$failed} failed)";
}
if (! empty($skipReasons)) {
$summary = collect($skipReasons)
->sortDesc()
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
->take(3)
->implode(', ');
if ($summary !== '') {
$message .= " Skip reasons: {$summary}.";
}
}
$message .= '.';
Notification::make()
->title('Bulk Prune Completed')
->body($message)
->icon('heroicon-o-check-circle')
->success()
->sendToDatabase($run->user)
->send();
}
}
/**
* @param array<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;
use App\Models\BulkOperationRun;
use App\Models\PolicyVersion;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use App\Jobs\Operations\PolicyVersionRestoreWorkerJob;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
use RuntimeException;
class BulkPolicyVersionRestoreJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $policyVersionIds
* @param array<string, mixed> $context
*/
public function __construct(
public int $bulkRunId,
) {}
public int $tenantId,
public int $userId,
public array $policyVersionIds,
?OperationRun $operationRun = null,
public array $context = [],
) {
$this->operationRun = $operationRun;
}
public function handle(BulkOperationService $service): void
public function handle(OperationRunService $runs): void
{
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
if (! $this->operationRun instanceof OperationRun) {
throw new RuntimeException('OperationRun is required for BulkPolicyVersionRestoreJob.');
}
if (! $run || $run->status !== 'pending') {
$this->operationRun->refresh();
if ($this->operationRun->status === 'completed') {
return;
}
$service->start($run);
$runs->updateRun($this->operationRun, 'running');
$itemCount = 0;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$ids = $this->normalizeIds($this->policyVersionIds);
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
foreach (($run->item_ids ?? []) as $versionId) {
$itemCount++;
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$chunkSize = max(1, $chunkSize);
try {
/** @var PolicyVersion|null $version */
$version = PolicyVersion::withTrashed()
->where('tenant_id', $run->tenant_id)
->whereKey($versionId)
->first();
if (! $version) {
$service->recordFailure($run, (string) $versionId, 'Policy version not found');
$failed++;
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Restore Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
continue;
}
if (! $version->trashed()) {
$service->recordSkippedWithReason($run, (string) $version->id, 'Not archived');
$skipped++;
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
continue;
}
$version->restore();
$service->recordSuccess($run);
$succeeded++;
} catch (Throwable $e) {
$service->recordFailure($run, (string) $versionId, $e->getMessage());
$failed++;
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Restore Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
foreach (array_chunk($ids, $chunkSize) as $chunk) {
foreach ($chunk as $policyVersionId) {
dispatch(new PolicyVersionRestoreWorkerJob(
tenantId: $this->tenantId,
userId: $this->userId,
policyVersionId: $policyVersionId,
operationRun: $this->operationRun,
context: $this->context,
));
}
if ($itemCount % $chunkSize === 0) {
$run->refresh();
}
}
$service->complete($run);
if ($run->user) {
$message = "Restored {$succeeded} policy versions";
if ($skipped > 0) {
$message .= " ({$skipped} skipped)";
}
if ($failed > 0) {
$message .= " ({$failed} failed)";
}
if (! empty($skipReasons)) {
$summary = collect($skipReasons)
->sortDesc()
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
->take(3)
->implode(', ');
if ($summary !== '') {
$message .= " Skip reasons: {$summary}.";
}
}
$message .= '.';
Notification::make()
->title('Bulk Restore Completed')
->body($message)
->icon('heroicon-o-check-circle')
->success()
->sendToDatabase($run->user)
->send();
}
}
/**
* @param array<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;
use App\Models\BulkOperationRun;
use App\Models\RestoreRun;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use App\Jobs\Operations\RestoreRunDeleteWorkerJob;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
use RuntimeException;
class BulkRestoreRunDeleteJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int, mixed> $restoreRunIds
* @param array<string, mixed> $context
*/
public function __construct(
public int $bulkRunId,
) {}
public int $tenantId,
public int $userId,
public array $restoreRunIds,
?OperationRun $operationRun = null,
public array $context = [],
) {
$this->operationRun = $operationRun;
}
public function handle(BulkOperationService $service): void
public function handle(OperationRunService $runs): void
{
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
if (! $this->operationRun instanceof OperationRun) {
throw new RuntimeException('OperationRun is required for BulkRestoreRunDeleteJob.');
}
if (! $run || $run->status !== 'pending') {
$this->operationRun->refresh();
if ($this->operationRun->status === 'completed') {
return;
}
$service->start($run);
$runs->updateRun($this->operationRun, 'running');
try {
$itemCount = 0;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$ids = $this->normalizeIds($this->restoreRunIds);
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
foreach (($run->item_ids ?? []) as $restoreRunId) {
$itemCount++;
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$chunkSize = max(1, $chunkSize);
try {
/** @var RestoreRun|null $restoreRun */
$restoreRun = RestoreRun::withTrashed()
->where('tenant_id', $run->tenant_id)
->whereKey($restoreRunId)
->first();
if (! $restoreRun) {
$service->recordFailure($run, (string) $restoreRunId, 'Restore run not found');
$failed++;
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Delete Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
continue;
}
if ($restoreRun->trashed()) {
$service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Already archived');
$skipped++;
$skipReasons['Already archived'] = ($skipReasons['Already archived'] ?? 0) + 1;
continue;
}
if (! $restoreRun->isDeletable()) {
$reason = "Not deletable (status: {$restoreRun->status})";
$service->recordSkippedWithReason($run, (string) $restoreRun->id, $reason);
$skipped++;
$skipReasons[$reason] = ($skipReasons[$reason] ?? 0) + 1;
continue;
}
$restoreRun->delete();
$service->recordSuccess($run);
$succeeded++;
} catch (Throwable $e) {
$service->recordFailure($run, (string) $restoreRunId, $e->getMessage());
$failed++;
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Delete Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
}
if ($itemCount % $chunkSize === 0) {
$run->refresh();
}
foreach (array_chunk($ids, $chunkSize) as $chunk) {
foreach ($chunk as $restoreRunId) {
dispatch(new RestoreRunDeleteWorkerJob(
tenantId: $this->tenantId,
userId: $this->userId,
restoreRunId: $restoreRunId,
operationRun: $this->operationRun,
context: $this->context,
));
}
$service->complete($run);
if ($run->user) {
$message = "Deleted {$succeeded} restore runs";
if ($skipped > 0) {
$message .= " ({$skipped} skipped)";
}
if ($failed > 0) {
$message .= " ({$failed} failed)";
}
if (! empty($skipReasons)) {
$summary = collect($skipReasons)
->sortDesc()
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
->take(3)
->implode(', ');
if ($summary !== '') {
$message .= " Skip reasons: {$summary}.";
}
}
$message .= '.';
Notification::make()
->title('Bulk Delete Completed')
->body($message)
->icon('heroicon-o-check-circle')
->success()
->sendToDatabase($run->user)
->send();
}
} catch (Throwable $e) {
$service->fail($run, $e->getMessage());
$run->refresh();
$run->load('user');
if ($run->user) {
Notification::make()
->title('Bulk Delete Failed')
->body($e->getMessage())
->icon('heroicon-o-x-circle')
->danger()
->sendToDatabase($run->user)
->send();
}
throw $e;
}
}
/**
* @param array<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;
use App\Models\BulkOperationRun;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Services\BulkOperationService;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -17,19 +23,41 @@ class BulkRestoreRunForceDeleteJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $bulkRunId,
) {}
public int $tenantId,
public int $userId,
/** @var array<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') {
return;
public function handle(OperationRunService $operationRunService): void
{
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new \RuntimeException('Tenant not found.');
}
$service->start($run);
$user = User::query()->find($this->userId);
if (! $user instanceof User) {
throw new \RuntimeException('User not found.');
}
$ids = collect($this->restoreRunIds)
->map(static fn ($id): int => (int) $id)
->unique()
->sort()
->values()
->all();
$itemCount = 0;
$succeeded = 0;
@ -37,34 +65,57 @@ public function handle(BulkOperationService $service): void
$skipped = 0;
$skipReasons = [];
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failures = [];
$totalItems = count($ids);
$failureThreshold = (int) floor($totalItems / 2);
foreach (($run->item_ids ?? []) as $restoreRunId) {
foreach ($ids as $restoreRunId) {
$itemCount++;
try {
/** @var RestoreRun|null $restoreRun */
$restoreRun = RestoreRun::withTrashed()
->where('tenant_id', $run->tenant_id)
->where('tenant_id', $tenant->getKey())
->whereKey($restoreRunId)
->first();
if (! $restoreRun) {
$service->recordFailure($run, (string) $restoreRunId, 'Restore run not found');
$failed++;
$failures[] = ['code' => 'restore_run.not_found', 'message' => "Restore run {$restoreRunId} not found."];
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
summaryCounts: [
'total' => $totalItems,
'processed' => $itemCount,
'succeeded' => $succeeded,
'failed' => $failed,
'skipped' => $skipped,
'deleted' => $succeeded,
],
failures: array_merge($failures, [
['code' => 'restore_run.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
]),
);
}
if ($run->user) {
if ($user) {
Notification::make()
->title('Bulk Force Delete Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
@ -75,7 +126,6 @@ public function handle(BulkOperationService $service): void
}
if (! $restoreRun->trashed()) {
$service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Not archived');
$skipped++;
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
@ -83,38 +133,76 @@ public function handle(BulkOperationService $service): void
}
$restoreRun->forceDelete();
$service->recordSuccess($run);
$succeeded++;
} catch (Throwable $e) {
$service->recordFailure($run, (string) $restoreRunId, $e->getMessage());
$failed++;
$failures[] = ['code' => 'restore_run.force_delete.failed', 'message' => $e->getMessage()];
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
summaryCounts: [
'total' => $totalItems,
'processed' => $itemCount,
'succeeded' => $succeeded,
'failed' => $failed,
'skipped' => $skipped,
'deleted' => $succeeded,
],
failures: array_merge($failures, [
['code' => 'restore_run.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
]),
);
}
if ($run->user) {
if ($user) {
Notification::make()
->title('Bulk Force Delete Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
return;
}
}
if ($itemCount % $chunkSize === 0) {
$run->refresh();
}
}
$service->complete($run);
$outcome = OperationRunOutcome::Succeeded->value;
if (! $run->user) {
return;
if ($failed > 0 && $failed < $totalItems) {
$outcome = OperationRunOutcome::PartiallySucceeded->value;
}
if ($failed >= $totalItems && $totalItems > 0) {
$outcome = OperationRunOutcome::Failed->value;
}
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: $outcome,
summaryCounts: [
'total' => $totalItems,
'processed' => $totalItems,
'succeeded' => $succeeded,
'failed' => $failed,
'skipped' => $skipped,
'deleted' => $succeeded,
],
failures: $failures,
);
}
$message = "Force deleted {$succeeded} restore runs";
@ -144,7 +232,12 @@ public function handle(BulkOperationService $service): void
->body($message)
->icon('heroicon-o-check-circle')
->success()
->sendToDatabase($run->user)
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
}

View File

@ -2,9 +2,15 @@
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Services\BulkOperationService;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -17,19 +23,41 @@ class BulkRestoreRunRestoreJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $bulkRunId,
) {}
public int $tenantId,
public int $userId,
/** @var array<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') {
return;
public function handle(OperationRunService $operationRunService): void
{
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new \RuntimeException('Tenant not found.');
}
$service->start($run);
$user = User::query()->find($this->userId);
if (! $user instanceof User) {
throw new \RuntimeException('User not found.');
}
$ids = collect($this->restoreRunIds)
->map(static fn ($id): int => (int) $id)
->unique()
->sort()
->values()
->all();
$itemCount = 0;
$succeeded = 0;
@ -37,34 +65,56 @@ public function handle(BulkOperationService $service): void
$skipped = 0;
$skipReasons = [];
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failures = [];
$totalItems = count($ids);
$failureThreshold = (int) floor($totalItems / 2);
foreach (($run->item_ids ?? []) as $restoreRunId) {
foreach ($ids as $restoreRunId) {
$itemCount++;
try {
/** @var RestoreRun|null $restoreRun */
$restoreRun = RestoreRun::withTrashed()
->where('tenant_id', $run->tenant_id)
->where('tenant_id', $tenant->getKey())
->whereKey($restoreRunId)
->first();
if (! $restoreRun) {
$service->recordFailure($run, (string) $restoreRunId, 'Restore run not found');
$failed++;
$failures[] = ['code' => 'restore_run.not_found', 'message' => "Restore run {$restoreRunId} not found."];
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
summaryCounts: [
'total' => $totalItems,
'processed' => $itemCount,
'succeeded' => $succeeded,
'failed' => $failed,
'skipped' => $skipped,
],
failures: array_merge($failures, [
['code' => 'restore_run.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
]),
);
}
if ($run->user) {
if ($user) {
Notification::make()
->title('Bulk Restore Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
@ -75,7 +125,6 @@ public function handle(BulkOperationService $service): void
}
if (! $restoreRun->trashed()) {
$service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Not archived');
$skipped++;
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
@ -83,38 +132,74 @@ public function handle(BulkOperationService $service): void
}
$restoreRun->restore();
$service->recordSuccess($run);
$succeeded++;
} catch (Throwable $e) {
$service->recordFailure($run, (string) $restoreRunId, $e->getMessage());
$failed++;
$failures[] = ['code' => 'restore_run.restore.failed', 'message' => $e->getMessage()];
if ($failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
summaryCounts: [
'total' => $totalItems,
'processed' => $itemCount,
'succeeded' => $succeeded,
'failed' => $failed,
'skipped' => $skipped,
],
failures: array_merge($failures, [
['code' => 'restore_run.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
]),
);
}
if ($run->user) {
if ($user) {
Notification::make()
->title('Bulk Restore Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
return;
}
}
if ($itemCount % $chunkSize === 0) {
$run->refresh();
}
}
$service->complete($run);
$outcome = OperationRunOutcome::Succeeded->value;
if (! $run->user) {
return;
if ($failed > 0 && $failed < $totalItems) {
$outcome = OperationRunOutcome::PartiallySucceeded->value;
}
if ($failed >= $totalItems && $totalItems > 0) {
$outcome = OperationRunOutcome::Failed->value;
}
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: $outcome,
summaryCounts: [
'total' => $totalItems,
'processed' => $totalItems,
'succeeded' => $succeeded,
'failed' => $failed,
'skipped' => $skipped,
],
failures: $failures,
);
}
$message = "Restored {$succeeded} restore runs";
@ -144,7 +229,12 @@ public function handle(BulkOperationService $service): void
->body($message)
->icon('heroicon-o-check-circle')
->success()
->sendToDatabase($run->user)
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
}
}

View File

@ -2,151 +2,92 @@
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Intune\PolicySyncService;
use Filament\Notifications\Notification;
use App\Jobs\Operations\TenantSyncWorkerJob;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
use RuntimeException;
class BulkTenantSyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $bulkRunId) {}
public ?OperationRun $operationRun = null;
public function handle(BulkOperationService $service, PolicySyncService $syncService): void
/**
* @param array<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;
}
$service->start($run);
$runs->updateRun($this->operationRun, 'running');
try {
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
$itemCount = 0;
$ids = $this->normalizeIds($this->tenantIds);
$supported = config('tenantpilot.supported_policy_types');
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
$failureThreshold = (int) floor($totalItems / 2);
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
$chunkSize = max(1, $chunkSize);
foreach (($run->item_ids ?? []) as $tenantId) {
$itemCount++;
try {
$tenant = Tenant::query()->whereKey($tenantId)->first();
if (! $tenant) {
$service->recordFailure($run, (string) $tenantId, 'Tenant not found');
if ($run->failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Sync Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
continue;
}
if (! $tenant->isActive()) {
$service->recordSkippedWithReason($run, (string) $tenantId, 'Tenant is not active');
continue;
}
if (! $run->user || ! $run->user->canSyncTenant($tenant)) {
$service->recordSkippedWithReason($run, (string) $tenantId, 'Not authorized to sync tenant');
continue;
}
$syncService->syncPolicies($tenant, $supported);
$service->recordSuccess($run);
} catch (Throwable $e) {
$service->recordFailure($run, (string) $tenantId, $e->getMessage());
if ($run->failed > $failureThreshold) {
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
if ($run->user) {
Notification::make()
->title('Bulk Sync Aborted')
->body('Circuit breaker triggered: too many failures (>50%).')
->icon('heroicon-o-exclamation-triangle')
->danger()
->sendToDatabase($run->user)
->send();
}
return;
}
}
if ($itemCount % $chunkSize === 0) {
$run->refresh();
}
foreach (array_chunk($ids, $chunkSize) as $chunk) {
foreach ($chunk as $targetTenantId) {
dispatch(new TenantSyncWorkerJob(
tenantId: $targetTenantId,
userId: $this->userId,
operationRun: $this->operationRun,
context: $this->context,
));
}
$service->complete($run);
if ($run->user) {
$message = "Synced {$run->succeeded} tenant(s)";
if ($run->skipped > 0) {
$message .= " ({$run->skipped} skipped)";
}
if ($run->failed > 0) {
$message .= " ({$run->failed} failed)";
}
$message .= '.';
Notification::make()
->title('Bulk Sync Completed')
->body($message)
->icon('heroicon-o-check-circle')
->success()
->sendToDatabase($run->user)
->send();
}
} catch (Throwable $e) {
$service->fail($run, $e->getMessage());
$run->refresh();
$run->load('user');
if ($run->user) {
Notification::make()
->title('Bulk Sync Failed')
->body($e->getMessage())
->icon('heroicon-o-x-circle')
->danger()
->sendToDatabase($run->user)
->send();
}
throw $e;
}
}
/**
* @param array<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;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Notifications\RunStatusChangedNotification;
use App\Services\BulkOperationService;
use App\Services\Intune\VersionService;
use App\Jobs\Operations\CapturePolicySnapshotWorkerJob;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
use RuntimeException;
class CapturePolicySnapshotJob implements ShouldQueue
{
@ -21,87 +19,48 @@ class CapturePolicySnapshotJob implements ShouldQueue
use Queueable;
use SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<string, mixed> $context
*/
public function __construct(
public int $bulkOperationRunId,
public int $tenantId,
public int $userId,
public int $policyId,
public bool $includeAssignments = true,
public bool $includeScopeTags = true,
public ?string $createdBy = null,
) {}
public function handle(BulkOperationService $bulkOperationService, VersionService $versionService): void
{
$run = BulkOperationRun::query()->with(['tenant', 'user'])->find($this->bulkOperationRunId);
if (! $run) {
return;
}
$policy = Policy::query()->with('tenant')->find($this->policyId);
if (! $policy || ! $policy->tenant) {
$bulkOperationService->abort($run, 'policy_not_found');
$this->notifyStatus($run, 'failed');
return;
}
$this->notifyStatus($run, 'queued');
$bulkOperationService->start($run);
$this->notifyStatus($run, 'running');
try {
$versionService->captureFromGraph(
tenant: $policy->tenant,
policy: $policy,
createdBy: $this->createdBy,
includeAssignments: $this->includeAssignments,
includeScopeTags: $this->includeScopeTags,
);
$bulkOperationService->recordSuccess($run);
$bulkOperationService->complete($run);
$this->notifyStatus($run, $run->refresh()->status);
} catch (Throwable $e) {
$bulkOperationService->recordFailure(
run: $run,
itemId: (string) $policy->getKey(),
reason: $bulkOperationService->sanitizeFailureReason($e->getMessage())
);
$bulkOperationService->complete($run);
$this->notifyStatus($run->refresh(), $run->status);
throw $e;
}
?OperationRun $operationRun = null,
public array $context = [],
) {
$this->operationRun = $operationRun;
}
private function notifyStatus(BulkOperationRun $run, string $status): void
public function handle(OperationRunService $runs): void
{
if (! $run->relationLoaded('user')) {
$run->loadMissing('user');
if (! $this->operationRun instanceof OperationRun) {
throw new RuntimeException('OperationRun is required for CapturePolicySnapshotJob.');
}
if (! $run->user) {
$this->operationRun->refresh();
if ($this->operationRun->status === 'completed') {
return;
}
$normalizedStatus = $status === 'pending' ? 'queued' : $status;
$runs->updateRun($this->operationRun, 'running');
$runs->incrementSummaryCounts($this->operationRun, ['total' => 1]);
$run->user->notify(new RunStatusChangedNotification([
'tenant_id' => (int) $run->tenant_id,
'run_type' => 'bulk_operation',
'run_id' => (int) $run->getKey(),
'status' => (string) $normalizedStatus,
'counts' => [
'total' => (int) $run->total_items,
'processed' => (int) $run->processed_items,
'succeeded' => (int) $run->succeeded,
'failed' => (int) $run->failed,
'skipped' => (int) $run->skipped,
],
]));
dispatch(new CapturePolicySnapshotWorkerJob(
tenantId: $this->tenantId,
userId: $this->userId,
policyId: $this->policyId,
includeAssignments: $this->includeAssignments,
includeScopeTags: $this->includeScopeTags,
createdBy: $this->createdBy,
operationRun: $this->operationRun,
context: $this->context,
));
}
}

View File

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

View File

@ -2,17 +2,12 @@
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BulkOperationRun;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Drift\DriftFindingGenerator;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -28,62 +23,78 @@ class GenerateDriftFindingsJob implements ShouldQueue
public ?OperationRun $operationRun = null;
/**
* @param array<string, mixed> $context
*/
public function __construct(
public int $tenantId,
public int $userId,
public int $baselineRunId,
public int $currentRunId,
public string $scopeKey,
public int $bulkOperationRunId,
?OperationRun $operationRun = null
?OperationRun $operationRun = null,
public array $context = [],
) {
$this->operationRun = $operationRun;
}
public function middleware(): array
{
return [new TrackOperationRun];
}
/**
* Execute the job.
*/
public function handle(DriftFindingGenerator $generator, BulkOperationService $bulkOperationService): void
{
public function handle(
DriftFindingGenerator $generator,
OperationRunService $runs,
TargetScopeConcurrencyLimiter $limiter,
): void {
Log::info('GenerateDriftFindingsJob: started', [
'tenant_id' => $this->tenantId,
'baseline_run_id' => $this->baselineRunId,
'current_run_id' => $this->currentRunId,
'scope_key' => $this->scopeKey,
'bulk_operation_run_id' => $this->bulkOperationRunId,
]);
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new RuntimeException('Tenant not found.');
if (! $this->operationRun instanceof OperationRun) {
throw new RuntimeException('OperationRun is required for drift generation.');
}
$baseline = InventorySyncRun::query()->find($this->baselineRunId);
if (! $baseline instanceof InventorySyncRun) {
throw new RuntimeException('Baseline run not found.');
$this->operationRun->refresh();
if ($this->operationRun->status === 'completed') {
return;
}
$current = InventorySyncRun::query()->find($this->currentRunId);
if (! $current instanceof InventorySyncRun) {
throw new RuntimeException('Current run not found.');
$opContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$targetScope = is_array($opContext['target_scope'] ?? null) ? $opContext['target_scope'] : [];
$lock = $limiter->acquireSlot($this->tenantId, $targetScope);
if (! $lock) {
$delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3);
$this->release(max(1, $delay));
return;
}
$run = BulkOperationRun::query()
->where('tenant_id', $tenant->getKey())
->find($this->bulkOperationRunId);
if (! $run instanceof BulkOperationRun) {
throw new RuntimeException('Bulk operation run not found.');
}
$bulkOperationService->start($run);
try {
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new RuntimeException('Tenant not found.');
}
$baseline = InventorySyncRun::query()->find($this->baselineRunId);
if (! $baseline instanceof InventorySyncRun) {
throw new RuntimeException('Baseline run not found.');
}
$current = InventorySyncRun::query()->find($this->currentRunId);
if (! $current instanceof InventorySyncRun) {
throw new RuntimeException('Current run not found.');
}
$runs->updateRun($this->operationRun, 'running');
$counts = is_array($this->operationRun->summary_counts ?? null) ? $this->operationRun->summary_counts : [];
if ((int) ($counts['total'] ?? 0) === 0) {
$runs->incrementSummaryCounts($this->operationRun, ['total' => 1]);
}
$created = $generator->generate(
tenant: $tenant,
baseline: $baseline,
@ -96,118 +107,40 @@ public function handle(DriftFindingGenerator $generator, BulkOperationService $b
'baseline_run_id' => $this->baselineRunId,
'current_run_id' => $this->currentRunId,
'scope_key' => $this->scopeKey,
'bulk_operation_run_id' => $this->bulkOperationRunId,
'created_findings_count' => $created,
]);
$bulkOperationService->recordSuccess($run);
$bulkOperationService->complete($run);
$runs->incrementSummaryCounts($this->operationRun, [
'processed' => 1,
'succeeded' => 1,
'created' => $created,
]);
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->updateRun(
$this->operationRun,
'completed',
'succeeded',
['findings_created' => $created]
);
}
$this->notifyStatus($run->refresh());
$runs->maybeCompleteBulkRun($this->operationRun);
} catch (Throwable $e) {
Log::error('GenerateDriftFindingsJob: failed', [
'tenant_id' => $this->tenantId,
'baseline_run_id' => $this->baselineRunId,
'current_run_id' => $this->currentRunId,
'scope_key' => $this->scopeKey,
'bulk_operation_run_id' => $this->bulkOperationRunId,
'error' => $e->getMessage(),
]);
$bulkOperationService->recordFailure(
run: $run,
itemId: $this->scopeKey,
reason: $e->getMessage(),
reasonCode: 'unknown',
);
$runs->incrementSummaryCounts($this->operationRun, [
'processed' => 1,
'failed' => 1,
]);
$bulkOperationService->fail($run, $e->getMessage());
$runs->appendFailures($this->operationRun, [[
'code' => 'drift.generate.failed',
'message' => $e->getMessage(),
]]);
// TrackOperationRun middleware might catch this, but explicit fail ensures structure
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->failRun($this->operationRun, $e);
}
$this->notifyStatus($run->refresh());
$runs->maybeCompleteBulkRun($this->operationRun);
throw $e;
}
}
private function notifyStatus(BulkOperationRun $run): void
{
try {
if (! $run->relationLoaded('user')) {
$run->loadMissing('user');
}
if (! $run->user) {
return;
}
$tenant = Tenant::query()->find((int) $run->tenant_id);
if (! $tenant instanceof Tenant) {
return;
}
$status = $run->statusBucket();
$title = match ($status) {
'queued' => 'Drift generation queued',
'running' => 'Drift generation started',
'succeeded' => 'Drift generation completed',
'partially succeeded' => 'Drift generation completed (partial)',
default => 'Drift generation failed',
};
$body = sprintf(
'Total: %d, processed: %d, succeeded: %d, failed: %d, skipped: %d.',
(int) $run->total_items,
(int) $run->processed_items,
(int) $run->succeeded,
(int) $run->failed,
(int) $run->skipped,
);
$notification = Notification::make()
->title($title)
->body($body)
->actions([
Action::make('view_run')
->label('View run')
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $tenant) : OperationRunLinks::index($tenant)),
]);
match ($status) {
'succeeded' => $notification->success(),
'partially succeeded' => $notification->warning(),
'queued', 'running' => $notification->info(),
default => $notification->danger(),
};
$notification
->sendToDatabase($run->user)
->send();
} catch (Throwable $e) {
Log::warning('GenerateDriftFindingsJob: status notification failed', [
'tenant_id' => (int) $run->tenant_id,
'bulk_operation_run_id' => (int) $run->getKey(),
'error' => $e->getMessage(),
]);
} finally {
$lock->release();
}
}
}

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

View File

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

View File

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

View File

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

View File

@ -51,11 +51,14 @@ public function handle(RestoreRun $restoreRun): void
[$opStatus, $opOutcome, $failures] = $this->mapStatus($status);
$summaryCounts = [
'assignments_success' => $restoreRun->getSuccessfulAssignmentsCount(),
'assignments_failed' => $restoreRun->getFailedAssignmentsCount(),
'assignments_skipped' => $restoreRun->getSkippedAssignmentsCount(),
];
$summaryCounts = [];
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
foreach (['total', 'succeeded', 'failed', 'skipped'] as $key) {
if (array_key_exists($key, $metadata) && is_numeric($metadata[$key])) {
$summaryCounts[$key] = (int) $metadata[$key];
}
}
$this->service->updateRun(
$opRun,

View File

@ -4,16 +4,14 @@
use App\Jobs\AddPoliciesToBackupSetJob;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\RunIdempotency;
use Filament\Actions\BulkAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\TextColumn;
@ -23,7 +21,6 @@
use Filament\Tables\TableComponent;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
@ -203,7 +200,7 @@ public function table(Table $table): Table
->where('tenant_id', $tenant->getKey())
->exists();
})
->action(function (Collection $records, BulkOperationService $bulkOperationService): void {
->action(function (Collection $records): void {
$backupSet = BackupSet::query()->findOrFail($this->backupSetId);
$tenant = null;
@ -269,131 +266,58 @@ public function table(Table $table): Table
sort($policyIds);
$idempotencyKey = RunIdempotency::buildKey(
tenantId: (int) $tenant->getKey(),
operationType: 'backup_set.add_policies',
targetId: (string) $backupSet->getKey(),
context: [
'policy_ids' => $policyIds,
'include_assignments' => (bool) $this->include_assignments,
'include_scope_tags' => (bool) $this->include_scope_tags,
'include_foundations' => (bool) $this->include_foundations,
],
);
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($policyIds);
// --- Phase 3: Canonical Operation Run Start ---
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
$opRun = $opService->enqueueBulkOperation(
tenant: $tenant,
type: 'backup_set.add_policies',
inputs: [
'backup_set_id' => $backupSet->id,
'policy_ids' => $policyIds,
'options' => [
'include_assignments' => (bool) $this->include_assignments,
'include_scope_tags' => (bool) $this->include_scope_tags,
'include_foundations' => (bool) $this->include_foundations,
],
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
initiator: $user
);
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $user, $backupSet, $policyIds): void {
$fingerprint = (string) data_get($operationRun?->context ?? [], 'idempotency.fingerprint', '');
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
Notification::make()
->title('Add policies already queued')
->body('A matching run is already queued or running. Open the run to monitor progress.')
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->info()
->send();
return;
}
// ----------------------------------------------
$existingRun = RunIdempotency::findActiveBulkOperationRun(
tenantId: (int) $tenant->getKey(),
idempotencyKey: $idempotencyKey,
);
if ($existingRun instanceof BulkOperationRun) {
Notification::make()
->title('Add policies already queued')
->body('A matching run is already queued or running. Open the run to monitor progress.')
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->info()
->send();
return;
}
$selectionPayload = [
'backup_set_id' => (int) $backupSet->getKey(),
'policy_ids' => $policyIds,
'options' => [
'include_assignments' => (bool) $this->include_assignments,
'include_scope_tags' => (bool) $this->include_scope_tags,
'include_foundations' => (bool) $this->include_foundations,
],
];
try {
$run = $bulkOperationService->createRun(
tenant: $tenant,
user: $user,
resource: 'backup_set',
action: 'add_policies',
itemIds: $selectionPayload,
totalItems: count($policyIds),
idempotencyKey: $idempotencyKey,
);
} catch (QueryException $exception) {
if ((string) $exception->getCode() === '23505') {
$existingRun = RunIdempotency::findActiveBulkOperationRun(
AddPoliciesToBackupSetJob::dispatch(
tenantId: (int) $tenant->getKey(),
idempotencyKey: $idempotencyKey,
userId: (int) $user->getKey(),
backupSetId: (int) $backupSet->getKey(),
policyIds: $policyIds,
options: [
'include_assignments' => (bool) $this->include_assignments,
'include_scope_tags' => (bool) $this->include_scope_tags,
'include_foundations' => (bool) $this->include_foundations,
],
idempotencyKey: $fingerprint,
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'backup_set_id' => (int) $backupSet->getKey(),
'policy_count' => count($policyIds),
],
);
if ($existingRun instanceof BulkOperationRun) {
Notification::make()
->title('Add policies already queued')
->body('A matching run is already queued or running. Open the run to monitor progress.')
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->info()
->send();
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Add policies already queued')
->body('A matching run is already queued or running. Open the run to monitor progress.')
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->info()
->send();
return;
}
}
throw $exception;
return;
}
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->dispatchOrFail($opRun, function () use ($run, $backupSet, $opRun): void {
AddPoliciesToBackupSetJob::dispatch(
bulkRunId: (int) $run->getKey(),
backupSetId: (int) $backupSet->getKey(),
includeAssignments: (bool) $this->include_assignments,
includeScopeTags: (bool) $this->include_scope_tags,
includeFoundations: (bool) $this->include_foundations,
operationRun: $opRun
);
});
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
\Filament\Actions\Action::make('view_run')

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

View File

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

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

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

View File

@ -0,0 +1,271 @@
<?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::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::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

@ -7,11 +7,17 @@
use App\Models\User;
use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification;
use App\Notifications\OperationRunQueued as OperationRunQueuedNotification;
use App\Services\Operations\BulkIdempotencyFingerprint;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\BulkRunContext;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\OpsUx\SummaryCountsNormalizer;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use ReflectionFunction;
use ReflectionMethod;
use Throwable;
class OperationRunService
@ -71,6 +77,124 @@ public function ensureRun(
}
}
public function ensureRunWithIdentity(
Tenant $tenant,
string $type,
array $identityInputs,
array $context,
?User $initiator = null
): OperationRun {
$hash = $this->calculateHash($tenant->id, $type, $identityInputs);
// Idempotency Check (Fast Path)
// We check specific status to match the partial unique index
$existing = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('run_identity_hash', $hash)
->whereIn('status', OperationRunStatus::values())
->where('status', '!=', OperationRunStatus::Completed->value)
->first();
if ($existing) {
return $existing;
}
// Create new run (race-safe via partial unique index)
try {
return OperationRun::create([
'tenant_id' => $tenant->id,
'user_id' => $initiator?->id,
'initiator_name' => $initiator?->name ?? 'System',
'type' => $type,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => $hash,
'context' => $context,
]);
} catch (QueryException $e) {
// Unique violation (active-run dedupe):
// - PostgreSQL: 23505
// - SQLite (tests): 23000 (generic integrity violation; message indicates UNIQUE constraint failed)
if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) {
throw $e;
}
$existing = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('run_identity_hash', $hash)
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
->first();
if ($existing) {
return $existing;
}
throw $e;
}
}
/**
* Standardized enqueue helper for bulk operations.
*
* Builds the canonical bulk context contract and ensures active-run dedupe
* is based on target scope + selection identity.
*
* @param array{entra_tenant_id?: mixed, directory_context_id?: mixed} $targetScope
* @param array{kind: string, ids_hash?: string, query_hash?: string} $selectionIdentity
* @param array<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(
OperationRun $run,
string $status,
@ -137,6 +261,51 @@ public function updateRun(
return $run;
}
/**
* Increment whitelisted summary_counts keys for a run.
*
* Uses a transaction + row lock to prevent lost updates when multiple workers
* update counts concurrently.
*
* @param array<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.
*
@ -146,7 +315,7 @@ public function updateRun(
public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = true): void
{
try {
$dispatcher();
$this->invokeDispatcher($dispatcher, $run);
if ($emitQueuedNotification && $run->wasRecentlyCreated && $run->user instanceof User) {
$run->user->notify(new OperationRunQueuedNotification($run));
@ -168,6 +337,107 @@ public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $em
}
}
/**
* Append failure entries to failure_summary (sanitized + bounded) without overwriting existing.
*
* @param array<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
{
return $this->updateRun(
@ -183,6 +453,30 @@ public function failRun(OperationRun $run, Throwable $e): OperationRun
);
}
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
{
$ref = null;
if (is_array($dispatcher) && count($dispatcher) === 2) {
$ref = new ReflectionMethod($dispatcher[0], (string) $dispatcher[1]);
} elseif (is_string($dispatcher) && str_contains($dispatcher, '::')) {
[$class, $method] = explode('::', $dispatcher, 2);
$ref = new ReflectionMethod($class, $method);
} elseif ($dispatcher instanceof \Closure) {
$ref = new ReflectionFunction($dispatcher);
} elseif (is_object($dispatcher) && method_exists($dispatcher, '__invoke')) {
$ref = new ReflectionMethod($dispatcher, '__invoke');
}
if ($ref && $ref->getNumberOfParameters() >= 1) {
$dispatcher($run);
return;
}
$dispatcher();
}
protected function calculateHash(int $tenantId, string $type, array $inputs): string
{
$normalizedInputs = $this->normalizeInputs($inputs);
@ -258,27 +552,12 @@ protected function sanitizeFailures(array $failures): array
protected function sanitizeFailureCode(string $code): string
{
$code = strtolower(trim($code));
if ($code === '') {
return 'unknown';
}
return substr($code, 0, 80);
return RunFailureSanitizer::sanitizeCode($code);
}
protected function sanitizeMessage(string $message): string
{
$message = trim(str_replace(["\r", "\n"], ' ', $message));
// Redact obvious bearer tokens / secrets.
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', 'Bearer [REDACTED]', $message) ?? $message;
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\s*[:=]\s*[^\s]+/i', '$1=[REDACTED]', $message) ?? $message;
// Redact long opaque blobs that look token-like.
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
return substr($message, 0, 120);
return RunFailureSanitizer::sanitizeMessage($message);
}
/**

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

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,31 @@
<?php
namespace App\Support\OpsUx;
final class RunFailureSanitizer
{
public static function sanitizeCode(string $code): string
{
$code = strtolower(trim($code));
if ($code === '') {
return 'unknown';
}
return substr($code, 0, 80);
}
public static function sanitizeMessage(string $message): string
{
$message = trim(str_replace(["\r", "\n"], ' ', $message));
// Redact obvious bearer tokens / secrets.
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', 'Bearer [REDACTED]', $message) ?? $message;
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\s*[:=]\s*[^\s]+/i', '$1=[REDACTED]', $message) ?? $message;
// Redact long opaque blobs that look token-like.
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
return substr($message, 0, 120);
}
}

View File

@ -2,11 +2,10 @@
namespace App\Support;
use App\Models\BulkOperationRun;
use App\Models\RestoreRun;
use Illuminate\Support\Arr;
final class RunIdempotency
final class RestoreRunIdempotency
{
/**
* @param array<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));
}
public static function findActiveBulkOperationRun(int $tenantId, string $idempotencyKey): ?BulkOperationRun
{
return BulkOperationRun::query()
->where('tenant_id', $tenantId)
->where('idempotency_key', $idempotencyKey)
->whereIn('status', ['pending', 'running'])
->latest('id')
->first();
}
public static function findActiveRestoreRun(int $tenantId, string $idempotencyKey): ?RestoreRun
{
return RestoreRun::query()

View File

@ -320,6 +320,10 @@
'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3),
'recent_finished_seconds' => (int) env('TENANTPILOT_BULK_RECENT_FINISHED_SECONDS', 12),
'progress_widget_enabled' => (bool) env('TENANTPILOT_BULK_PROGRESS_WIDGET_ENABLED', true),
'concurrency' => [
'per_target_scope_max' => (int) env('TENANTPILOT_BULK_CONCURRENCY_PER_TARGET_SCOPE_MAX', 1),
'lock_ttl_seconds' => (int) env('TENANTPILOT_BULK_CONCURRENCY_LOCK_TTL_SECONDS', 900),
],
],
'inventory_sync' => [

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

View File

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

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

@ -9,9 +9,22 @@ ## Common commands (Sail-first)
- Boot: `./vendor/bin/sail up -d`
- Run migrations: `./vendor/bin/sail artisan migrate`
- Run targeted tests: `./vendor/bin/sail artisan test tests/Feature`
- 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.

View File

@ -10,9 +10,9 @@ ## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Ensure feature docs/paths are ready for implementation and review
- [ ] T001 Review and update quickstart commands in specs/056-remove-legacy-bulkops/quickstart.md
- [ ] T002 [P] Create discovery report scaffold in specs/056-remove-legacy-bulkops/discovery.md
- [ ] T003 [P] Add a “legacy history decision” section to specs/056-remove-legacy-bulkops/discovery.md
- [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
---
@ -20,14 +20,14 @@ ## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared primitives required for all bulk migrations and hardening
- [ ] T004 Populate the full repo-wide discovery sweep in specs/056-remove-legacy-bulkops/discovery.md (app/, resources/, database/, tests/)
- [ ] T005 [P] Add config keys for per-target scope concurrency (default=1) in config/tenantpilot.php
- [ ] T006 [P] Register new bulk operation types/labels/durations in app/Support/OperationCatalog.php
- [ ] T007 [P] Implement hybrid selection identity hasher in app/Services/Operations/BulkSelectionIdentity.php
- [ ] T008 [P] Implement idempotency fingerprint builder in app/Services/Operations/BulkIdempotencyFingerprint.php
- [ ] T009 [P] Implement per-target scope concurrency limiter (Cache locks) in app/Services/Operations/TargetScopeConcurrencyLimiter.php
- [ ] T010 Extend OperationRun identity inputs to include target scope + selection identity in app/Services/OperationRunService.php
- [ ] T011 Add a bulk enqueue helper that standardizes ensureRun + dispatchOrFail usage in app/Services/OperationRunService.php
- [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
@ -41,47 +41,47 @@ ## Phase 3: User Story 1 - Run-backed bulk actions are always observable (Priori
### Tests for User Story 1
- [ ] T012 [P] [US1] Add bulk enqueue idempotency test in tests/Feature/OpsUx/BulkEnqueueIdempotencyTest.php
- [ ] T013 [P] [US1] Add per-target concurrency default=1 test in tests/Feature/OpsUx/TargetScopeConcurrencyLimiterTest.php
- [ ] T014 [P] [US1] Add hybrid selection identity hashing test in tests/Unit/Operations/BulkSelectionIdentityTest.php
- [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
- [ ] T015 [P] [US1] Add OperationRun context shape helpers for bulk runs in app/Support/OpsUx/BulkRunContext.php
- [ ] T016 [P] [US1] Implement orchestrator job skeleton for bulk runs in app/Jobs/Operations/BulkOperationOrchestratorJob.php
- [ ] T017 [P] [US1] Implement worker job skeleton for bulk items in app/Jobs/Operations/BulkOperationWorkerJob.php
- [ ] T018 [US1] Ensure worker jobs update summary_counts via canonical whitelist in app/Services/OperationRunService.php
- [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.
- [ ] T019 [US1] Migrate policy bulk delete start action to OperationRun-backed flow in app/Filament/Resources/PolicyResource.php
- [ ] T020 [US1] Refactor policy bulk delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkPolicyDeleteJob.php
- [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
- [ ] T021 [US1] Migrate backup set bulk delete start action to OperationRun-backed flow in app/Filament/Resources/BackupSetResource.php
- [ ] T022 [US1] Refactor backup set bulk delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkBackupSetDeleteJob.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
- [ ] T023 [US1] Migrate policy version prune start action to OperationRun-backed flow in app/Filament/Resources/PolicyVersionResource.php
- [ ] T024 [US1] Refactor policy version prune execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkPolicyVersionPruneJob.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
- [ ] T025 [US1] Migrate policy version force delete start action to OperationRun-backed flow in app/Filament/Resources/PolicyVersionResource.php
- [ ] T026 [US1] Refactor policy version force delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkPolicyVersionForceDeleteJob.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
- [ ] T027 [US1] Migrate restore run bulk delete start action to OperationRun-backed flow in app/Filament/Resources/RestoreRunResource.php
- [ ] T028 [US1] Refactor restore run bulk delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkRestoreRunDeleteJob.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
- [ ] T029 [US1] Migrate tenant bulk sync start action to OperationRun-backed flow in app/Filament/Resources/TenantResource.php
- [ ] T030 [US1] Refactor tenant bulk sync execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkTenantSyncJob.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
- [ ] T031 [US1] Migrate policy snapshot capture action to OperationRun-backed flow in app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php
- [ ] T032 [US1] Refactor snapshot capture execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/CapturePolicySnapshotJob.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
- [ ] T033 [US1] Migrate drift generation flow to OperationRun-backed flow in app/Filament/Pages/DriftLanding.php
- [ ] T034 [US1] Remove legacy BulkOperationRun coupling from drift generator job in app/Jobs/GenerateDriftFindingsJob.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
- [ ] T035 [US1] Remove legacy “fallback link” usage and standardize view-run URLs to canonical OperationRun links in app/Jobs/AddPoliciesToBackupSetJob.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
@ -95,21 +95,21 @@ ## Phase 4: User Story 2 - Monitoring is the single source of run history (Prior
### Tests for User Story 2
- [ ] T036 [P] [US2] Add regression test that notifications do not link to BulkOperationRun resources in tests/Feature/OpsUx/NotificationViewRunLinkTest.php
- [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
- [ ] T037 [US2] Replace BulkOperationRun resource links with OperationRun links in app/Notifications/RunStatusChangedNotification.php
- [ ] T038 [US2] Replace BulkOperationRun resource links with OperationRun links in app/Filament/Resources/BackupSetResource.php
- [ ] T039 [US2] Replace BulkOperationRun resource links with OperationRun links in app/Filament/Resources/PolicyVersionResource.php
- [ ] T040 [US2] Replace BulkOperationRun resource links/redirects with OperationRun links in app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php
- [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
- [ ] T041 [US2] Remove legacy resource and pages: app/Filament/Resources/BulkOperationRunResource.php
- [ ] T042 [US2] Remove legacy resource pages: app/Filament/Resources/BulkOperationRunResource/Pages/ListBulkOperationRuns.php
- [ ] T043 [US2] Remove legacy resource pages: app/Filament/Resources/BulkOperationRunResource/Pages/ViewBulkOperationRun.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
- [ ] T044 [US2] Remove legacy authorization policy in app/Policies/BulkOperationRunPolicy.php
- [ ] T045 [US2] Remove BulkOperationRun gate registration in app/Providers/AppServiceProvider.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
@ -123,16 +123,16 @@ ## Phase 5: User Story 3 - Developers cant accidentally reintroduce legacy pa
### Tests for User Story 3
- [ ] T046 [P] [US3] Add “no legacy references” test guard in tests/Feature/Guards/NoLegacyBulkOperationsTest.php
- [ ] T047 [P] [US3] Add tenant isolation guard for bulk enqueue inputs in tests/Feature/OpsUx/BulkTenantIsolationTest.php
- [ ] T048 [P] [US3] Extend summary_counts whitelist coverage for bulk updates in tests/Feature/OpsUx/SummaryCountsWhitelistTest.php
- [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
- [ ] T049 [US3] Remove legacy BulkOperationRun unit tests in tests/Unit/BulkOperationRunStatusBucketTest.php
- [ ] T050 [US3] Remove legacy BulkOperationRun unit tests in tests/Unit/BulkOperationRunProgressTest.php
- [ ] T051 [US3] Remove legacy factory and update any dependent tests in database/factories/BulkOperationRunFactory.php
- [ ] T052 [US3] Remove legacy test seeder and update any dependent docs/tests in database/seeders/BulkOperationsTestSeeder.php
- [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
@ -142,8 +142,8 @@ ## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final removal of legacy DB artifacts and cleanup
- [ ] T053 Create migration to drop legacy bulk_operation_runs table in database/migrations/2026_01_18_000001_drop_bulk_operation_runs_table.php
- [ ] T054 Do NOT delete historical migrations; add forward drop migrations only
- [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)
@ -151,7 +151,7 @@ ## Phase 6: Polish & Cross-Cutting Concerns
- [ ] 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
- [ ] T056 Validate feature via targeted test run list and update notes in specs/056-remove-legacy-bulkops/quickstart.md
- [X] T056 Validate feature via targeted test run list and update notes in specs/056-remove-legacy-bulkops/quickstart.md
---
@ -166,15 +166,15 @@ ## Monitoring DB-only render guard (NFR-01)
## Legacy removal (FR-006)
- [ ] T062 Remove BulkOperationRun model + related database artifacts (after cutover)
- [X] T062 Remove BulkOperationRun model + related database artifacts (after cutover)
- Delete app/Models/BulkOperationRun.php (and any related models)
- Ensure no runtime references remain
- [ ] T063 Remove BulkOperationService and migrate all call sites to OperationRunService patterns
- [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
- [ ] T064 Add CI guard to prevent reintroduction of BulkOperationRun/BulkOperationService
- [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)
@ -205,7 +205,7 @@ ## Remote retry/backoff/jitter policy (NFR-03)
## Canonical “View run” sweep and guard (FR-005)
- [ ] T069 Perform a repo-wide sweep to ensure all “View run” links route to Monitoring → Operations → Run Detail
- [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
@ -213,6 +213,15 @@ ## Canonical “View run” sweep and guard (FR-005)
---
## 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

View File

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

View File

@ -4,7 +4,6 @@
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BulkOperationRun;
use App\Models\OperationRun;
use App\Models\User;
use App\Notifications\OperationRunQueued;
@ -68,14 +67,6 @@
'backup_schedule_run_id' => (int) $run->id,
]);
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->where('resource', 'backup_schedule')
->where('action', 'run')
->count())
->toBe(1);
Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
return $job->backupScheduleRunId === (int) $run->id
&& $job->operationRun instanceof OperationRun
@ -139,14 +130,6 @@
'backup_schedule_run_id' => (int) $run->id,
]);
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->where('resource', 'backup_schedule')
->where('action', 'retry')
->count())
->toBe(1);
Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
return $job->backupScheduleRunId === (int) $run->id
&& $job->operationRun instanceof OperationRun
@ -259,14 +242,6 @@
->count())
->toBe(2);
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->where('resource', 'backup_schedule')
->where('action', 'run')
->count())
->toBe(1);
Queue::assertPushed(RunBackupScheduleJob::class, 2);
$this->assertDatabaseCount('notifications', 1);
$this->assertDatabaseHas('notifications', [
@ -330,14 +305,6 @@
->count())
->toBe(2);
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->where('resource', 'backup_schedule')
->where('action', 'retry')
->count())
->toBe(1);
Queue::assertPushed(RunBackupScheduleJob::class, 2);
$this->assertDatabaseCount('notifications', 1);
$this->assertDatabaseHas('notifications', [

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\OperationRunService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
@ -18,13 +18,31 @@
$policies = Policy::factory()->count(25)->create(['tenant_id' => $tenant->id]);
$policyIds = $policies->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 25);
/** @var OperationRunService $service */
$service = app(OperationRunService::class);
$opRun = $service->ensureRun(
tenant: $tenant,
type: 'policy.delete',
inputs: [
'scope' => 'subset',
'policy_ids' => $policyIds,
],
initiator: $user,
);
// Simulate Async dispatch (this logic will be in Filament Action)
BulkPolicyDeleteJob::dispatch($run->id);
BulkPolicyDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $policyIds,
operationRun: $opRun,
);
Queue::assertPushed(BulkPolicyDeleteJob::class, function ($job) use ($run) {
return $job->bulkRunId === $run->id;
Queue::assertPushed(BulkPolicyDeleteJob::class, function ($job) use ($tenant, $user, $opRun, $policyIds) {
return $job->tenantId === (int) $tenant->getKey()
&& $job->userId === (int) $user->getKey()
&& $job->operationRun?->getKey() === $opRun->getKey()
&& $job->policyIds === $policyIds;
});
});

View File

@ -1,10 +1,11 @@
<?php
use App\Jobs\BulkPolicyDeleteJob;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\OperationRunService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
@ -15,18 +16,37 @@
$policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]);
$policyIds = $policies->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 10);
/** @var OperationRunService $service */
$service = app(OperationRunService::class);
// Simulate Sync execution
BulkPolicyDeleteJob::dispatchSync($run->id);
$opRun = $service->ensureRun(
tenant: $tenant,
type: 'policy.delete',
inputs: [
'scope' => 'subset',
'policy_ids' => $policyIds,
],
initiator: $user,
);
$run->refresh();
expect($run->status)->toBe('completed')
->and($run->processed_items)->toBe(10)
->and($run->audit_log_id)->not->toBeNull();
// Simulate Sync execution (workers will run immediately on sync queue)
BulkPolicyDeleteJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $policyIds,
operationRun: $opRun,
);
expect(\App\Models\AuditLog::where('action', 'bulk.policy.delete.completed')->exists())->toBeTrue();
$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,
'failed' => 0,
]);
$policies->each(function ($policy) {
expect($policy->refresh()->ignored_at)->not->toBeNull();

View File

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

View File

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

View File

@ -1,11 +1,12 @@
<?php
use App\Jobs\BulkPolicyExportJob;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\OperationRunService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
@ -24,15 +25,33 @@
'captured_at' => now(),
]);
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'export', [$policy->id], 1);
$opRun = OperationRun::create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'initiator_name' => $user->name,
'type' => 'policy.export',
'status' => 'queued',
'outcome' => 'pending',
'run_identity_hash' => 'policy-export-test',
'context' => [
'policy_ids' => [$policy->id],
'backup_name' => 'Feature Backup',
],
]);
// Simulate Sync
$job = new BulkPolicyExportJob($run->id, 'Feature Backup');
$job->handle($service);
$job = new BulkPolicyExportJob(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: [$policy->id],
backupName: 'Feature Backup',
backupDescription: null,
operationRun: $opRun,
);
$job->handle(app(OperationRunService::class));
$run->refresh();
expect($run->status)->toBe('completed');
$opRun->refresh();
expect($opRun->status)->toBe('completed');
$this->assertDatabaseHas('backup_sets', [
'name' => 'Feature Backup',

View File

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

View File

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

View File

@ -2,7 +2,7 @@
use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
@ -52,12 +52,14 @@
$runs->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id))->toBeNull());
$bulkRun = BulkOperationRun::query()
->where('resource', 'restore_run')
->where('action', 'force_delete')
$opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'restore_run.force_delete')
->latest('id')
->first();
expect($bulkRun)->not->toBeNull();
expect($bulkRun->status)->toBe('completed');
expect($opRun)->not->toBeNull();
expect($opRun->status)->toBe('completed');
expect($opRun->outcome)->toBeIn(['succeeded', 'partially_succeeded']);
expect((int) ($opRun->summary_counts['deleted'] ?? 0))->toBe(3);
});

View File

@ -1,105 +1,64 @@
<?php
use App\Livewire\BulkOperationProgress;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\OperationRun;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('progress widget shows running operations for current tenant and user', function () {
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
// Own running op
BulkOperationRun::factory()->create([
// Active op
OperationRun::factory()->create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'initiator_name' => $user->name,
'type' => 'policy.delete',
'status' => 'running',
'resource' => 'policy',
'action' => 'delete',
'total_items' => 100,
'processed_items' => 50,
'outcome' => 'pending',
'context' => ['scope' => 'subset'],
]);
// Completed op (should not show)
BulkOperationRun::factory()->create([
OperationRun::factory()->create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'initiator_name' => $user->name,
'type' => 'policy.delete',
'status' => 'completed',
'outcome' => 'succeeded',
'updated_at' => now()->subMinutes(5),
]);
// Other user's op (should not show)
$otherUser = User::factory()->create();
BulkOperationRun::factory()->create([
'tenant_id' => $tenant->id,
'user_id' => $otherUser->id,
'status' => 'running',
]);
auth()->login($user); // Login user explicitly for auth()->id() call in component
Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->assertSee('Delete Policy')
->assertSee('50 / 100');
->assertSee('Delete policies')
->assertDontSee('Unknown operation');
});
test('progress widget reconciles stale pending backup schedule runs', function () {
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create();
test('progress widget shows queued backup schedule runs as operation runs', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
'next_run_at' => now()->addHour(),
]);
$bulkRun = BulkOperationRun::factory()->create([
OperationRun::factory()->create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'status' => 'pending',
'resource' => 'backup_schedule',
'action' => 'run',
'total_items' => 1,
'processed_items' => 0,
'item_ids' => [(string) $schedule->id],
'initiator_name' => $user->name,
'type' => 'backup_schedule.run_now',
'status' => 'queued',
'outcome' => 'pending',
'context' => ['scope' => 'scheduled'],
'created_at' => now()->subMinutes(2),
'updated_at' => now()->subMinutes(2),
]);
BackupScheduleRun::query()->create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'scheduled_for' => now()->startOfMinute(),
'started_at' => now()->subMinute(),
'finished_at' => now(),
'status' => BackupScheduleRun::STATUS_SUCCESS,
'summary' => null,
]);
auth()->login($user);
Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->assertSee('Run Backup schedule')
->assertSee('1 / 1');
expect($bulkRun->refresh()->status)->toBe('completed');
->assertSee('Backup schedule run');
});

View File

@ -1,7 +1,7 @@
<?php
use App\Filament\Resources\PolicyVersionResource;
use App\Models\BulkOperationRun;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
@ -50,16 +50,16 @@
])
->assertHasNoTableBulkActionErrors();
$run = BulkOperationRun::query()
$run = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->where('resource', 'policy_version')
->where('action', 'prune')
->where('type', 'policy_version.prune')
->latest('id')
->first();
expect($run)->not->toBeNull();
$reasons = collect($run->failures ?? [])->pluck('reason')->all();
expect($reasons)->toContain('Current version')
->and($reasons)->toContain('Too recent');
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
expect((int) ($counts['processed'] ?? 0))->toBe(2);
expect((int) ($counts['skipped'] ?? 0))->toBe(2);
});

View File

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

View File

@ -1,7 +1,7 @@
<?php
use App\Filament\Resources\PolicyVersionResource;
use App\Models\BulkOperationRun;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
@ -36,18 +36,19 @@
->callTableBulkAction('bulk_restore_versions', collect([$version]))
->assertHasNoTableBulkActionErrors();
$run = BulkOperationRun::query()
$run = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->where('resource', 'policy_version')
->where('action', 'restore')
->where('type', 'policy_version.restore')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run->succeeded)->toBe(1)
->and($run->skipped)->toBe(0)
->and($run->failed)->toBe(0);
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
expect((int) ($counts['succeeded'] ?? 0))->toBe(1)
->and((int) ($counts['skipped'] ?? 0))->toBe(0)
->and((int) ($counts['failed'] ?? 0))->toBe(0);
$version->refresh();
expect($version->trashed())->toBeFalse();

View File

@ -2,7 +2,7 @@
use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
@ -45,18 +45,19 @@
->callTableBulkAction('bulk_restore', collect([$run]))
->assertHasNoTableBulkActionErrors();
$bulkRun = BulkOperationRun::query()
$opRun = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->where('resource', 'restore_run')
->where('action', 'restore')
->where('type', 'restore_run.restore')
->latest('id')
->first();
expect($bulkRun)->not->toBeNull();
expect($bulkRun->succeeded)->toBe(1)
->and($bulkRun->skipped)->toBe(0)
->and($bulkRun->failed)->toBe(0);
expect($opRun)->not->toBeNull();
$counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : [];
expect((int) ($counts['succeeded'] ?? 0))->toBe(1)
->and((int) ($counts['skipped'] ?? 0))->toBe(0)
->and((int) ($counts['failed'] ?? 0))->toBe(0);
$run->refresh();
expect($run->trashed())->toBeFalse();

View File

@ -1,22 +1,20 @@
<?php
use App\Jobs\BulkPolicySyncJob;
use App\Models\AuditLog;
use App\Models\BulkOperationRun;
use App\Jobs\SyncPoliciesJob;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\OperationRunService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('bulk sync updates selected policies from graph', function () {
$tenant = Tenant::factory()->create();
test('policy sync updates selected policies from graph and updates the operation run', function () {
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
$tenant->makeCurrent();
$user = User::factory()->create();
$policies = Policy::factory()
->count(3)
@ -25,6 +23,7 @@
'policy_type' => 'deviceConfiguration',
'platform' => 'windows10AndLater',
'last_synced_at' => null,
'ignored_at' => null,
]);
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
@ -67,17 +66,44 @@ public function request(string $method, string $path, array $options = []): Grap
}
});
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'sync', $policies->modelKeys(), 3);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
BulkPolicySyncJob::dispatchSync($run->id);
$selectedIds = $policies
->pluck('id')
->map(static fn ($id): int => (int) $id)
->sort()
->values()
->all();
$bulkRun = BulkOperationRun::query()->find($run->id);
expect($bulkRun)->not->toBeNull();
expect($bulkRun->status)->toBe('completed');
expect($bulkRun->total_items)->toBe(3);
expect($bulkRun->succeeded)->toBe(3);
expect($bulkRun->failed)->toBe(0);
$opRun = $runs->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: [
'scope' => 'subset',
'policy_ids' => $selectedIds,
],
initiator: null,
);
SyncPoliciesJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
types: null,
policyIds: $selectedIds,
operationRun: $opRun,
);
$opRun->refresh();
expect($opRun->status)->toBe('completed');
expect($opRun->outcome)->toBe('succeeded');
expect($opRun->summary_counts)->toMatchArray([
'total' => 3,
'processed' => 3,
'succeeded' => 3,
'failed' => 0,
'skipped' => 0,
]);
$policies->each(function (Policy $policy) {
$policy->refresh();
@ -88,6 +114,4 @@ public function request(string $method, string $path, array $options = []): Grap
'example' => 'value',
]);
});
expect(AuditLog::where('action', 'bulk.policy.sync.completed')->exists())->toBeTrue();
});

View File

@ -1,10 +1,11 @@
<?php
use App\Jobs\BulkPolicyUnignoreJob;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\OperationRunService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
@ -22,18 +23,34 @@
$policyIds = $policies->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'unignore', $policyIds, count($policyIds));
$opRun = OperationRun::create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'initiator_name' => $user->name,
'type' => 'policy.unignore',
'status' => 'queued',
'outcome' => 'pending',
'run_identity_hash' => 'policy-unignore-test',
'context' => [
'policy_ids' => $policyIds,
],
]);
BulkPolicyUnignoreJob::dispatchSync($run->id);
BulkPolicyUnignoreJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: $policyIds,
operationRun: $opRun,
);
$run->refresh();
$opRun->refresh();
expect($run->status)->toBe('completed')
->and($run->processed_items)->toBe(5)
->and($run->audit_log_id)->not->toBeNull();
expect($opRun->status)->toBe('completed');
expect(\App\Models\AuditLog::where('action', 'bulk.policy.unignore.completed')->exists())->toBeTrue();
$counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : [];
expect((int) ($counts['processed'] ?? 0))->toBe(5);
expect((int) ($counts['succeeded'] ?? 0))->toBe(5);
expect((int) ($counts['failed'] ?? 0))->toBe(0);
$policies->each(function (Policy $policy): void {
expect($policy->refresh()->ignored_at)->toBeNull();

View File

@ -5,7 +5,7 @@
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\RestoreRun;
@ -79,7 +79,7 @@
'recorded_at' => now(),
]);
BulkOperationRun::factory()->create([
OperationRun::factory()->create([
'tenant_id' => $tenantA->id,
'user_id' => $user->id,
'status' => 'completed',
@ -130,7 +130,7 @@
expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(RestoreRun::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(AuditLog::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(BulkOperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(BackupSchedule::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);

View File

@ -2,9 +2,8 @@
use App\Filament\Pages\DriftLanding;
use App\Jobs\GenerateDriftFindingsJob;
use App\Models\BulkOperationRun;
use App\Models\InventorySyncRun;
use App\Support\RunIdempotency;
use App\Models\OperationRun;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
@ -32,28 +31,26 @@
'finished_at' => now()->subDay(),
]);
$idempotencyKey = RunIdempotency::buildKey(
tenantId: (int) $tenant->getKey(),
operationType: 'drift.generate',
targetId: $scopeKey,
context: [
OperationRun::create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'initiator_name' => $user->name,
'type' => 'drift.generate',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => 'drift-zero-findings',
'summary_counts' => [
'total' => 1,
'processed' => 1,
'succeeded' => 1,
'failed' => 0,
'created' => 0,
],
'context' => [
'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(),
],
);
BulkOperationRun::factory()->for($tenant)->for($user)->create([
'resource' => 'drift',
'action' => 'generate',
'status' => 'completed',
'idempotency_key' => $idempotencyKey,
'item_ids' => [$scopeKey],
'total_items' => 1,
'processed_items' => 1,
'succeeded' => 1,
'failed' => 0,
'skipped' => 0,
]);
Livewire::test(DriftLanding::class)
@ -62,10 +59,5 @@
Queue::assertNothingPushed();
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('idempotency_key', $idempotencyKey)
->count())->toBe(1);
Queue::assertNotPushed(GenerateDriftFindingsJob::class);
});

View File

@ -2,12 +2,10 @@
use App\Filament\Pages\DriftLanding;
use App\Jobs\GenerateDriftFindingsJob;
use App\Models\BulkOperationRun;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Services\Graph\GraphClientInterface;
use App\Support\OperationRunLinks;
use App\Support\RunIdempotency;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
@ -46,33 +44,6 @@
Livewire::test(DriftLanding::class);
$idempotencyKey = RunIdempotency::buildKey(
tenantId: (int) $tenant->getKey(),
operationType: 'drift.generate',
targetId: $scopeKey,
context: [
'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(),
],
);
$bulkRun = BulkOperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('idempotency_key', $idempotencyKey)
->latest('id')
->first();
expect($bulkRun)->not->toBeNull();
expect($bulkRun->resource)->toBe('drift');
expect($bulkRun->action)->toBe('generate');
expect($bulkRun->status)->toBe('pending');
expect($bulkRun->item_ids)->toBe([
'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(),
]);
$opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'drift.generate')
@ -88,13 +59,12 @@
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($opRun, $tenant));
Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $bulkRun, $opRun): bool {
Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $opRun): bool {
return $job->tenantId === (int) $tenant->getKey()
&& $job->userId === (int) $user->getKey()
&& $job->baselineRunId === (int) $baseline->getKey()
&& $job->currentRunId === (int) $current->getKey()
&& $job->scopeKey === $scopeKey
&& $job->bulkOperationRunId === (int) $bulkRun->getKey()
&& $job->operationRun instanceof OperationRun
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
});
@ -126,22 +96,6 @@
Queue::assertPushed(GenerateDriftFindingsJob::class, 1);
$idempotencyKey = RunIdempotency::buildKey(
tenantId: (int) $tenant->getKey(),
operationType: 'drift.generate',
targetId: $scopeKey,
context: [
'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(),
],
);
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('idempotency_key', $idempotencyKey)
->count())->toBe(1);
expect(OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'drift.generate')
@ -166,7 +120,6 @@
Livewire::test(DriftLanding::class);
Queue::assertNothingPushed();
expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
});
@ -194,6 +147,5 @@
Livewire::test(DriftLanding::class);
Queue::assertNothingPushed();
expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
});

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