Compare commits
8 Commits
63a865d214
...
59b229cbad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b229cbad | ||
|
|
d718a127c5 | ||
|
|
0a6e1f7751 | ||
|
|
eef9618889 | ||
|
|
239a2e6af9 | ||
|
|
e7d2be16f2 | ||
|
|
99f2a6309d | ||
|
|
e603a1245e |
11
README.md
11
README.md
@ -27,6 +27,17 @@ ## TenantPilot setup
|
||||
- Ensure queue workers are running for jobs (e.g., policy sync) after deploy.
|
||||
- Keep secrets/env in Dokploy, never in code.
|
||||
|
||||
## Bulk operations (Feature 005)
|
||||
|
||||
- Bulk actions are available in Filament resource tables (Policies, Policy Versions, Backup Sets, Restore Runs).
|
||||
- Destructive operations require type-to-confirm at higher thresholds (e.g. `DELETE`).
|
||||
- Long-running bulk ops are queued; the bottom-right progress widget polls for active runs.
|
||||
|
||||
### Configuration
|
||||
|
||||
- `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`): job refresh/progress chunk size.
|
||||
- `TENANTPILOT_BULK_POLL_INTERVAL_SECONDS` (default `3`): Livewire polling interval for the progress widget (clamped to 1–10s).
|
||||
|
||||
## Intune RBAC Onboarding Wizard
|
||||
|
||||
- Entry point: Tenant detail in Filament (`Setup Intune RBAC` in the ⋯ ActionGroup). Visible only for active tenants with `app_client_id`.
|
||||
|
||||
@ -4,20 +4,29 @@
|
||||
|
||||
use App\Filament\Resources\BackupSetResource\Pages;
|
||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||
use App\Jobs\BulkBackupSetDeleteJob;
|
||||
use App\Jobs\BulkBackupSetForceDeleteJob;
|
||||
use App\Jobs\BulkBackupSetRestoreJob;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\BackupService;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Forms;
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use UnitEnum;
|
||||
|
||||
class BackupSetResource extends Resource
|
||||
@ -51,7 +60,11 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('created_at')->dateTime()->since(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TrashedFilter::make(),
|
||||
Tables\Filters\TrashedFilter::make()
|
||||
->label('Archived')
|
||||
->placeholder('Active')
|
||||
->trueLabel('All')
|
||||
->falseLabel('Archived'),
|
||||
])
|
||||
->actions([
|
||||
Actions\ViewAction::make()
|
||||
@ -131,7 +144,164 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([]);
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
BulkAction::make('bulk_delete')
|
||||
->label('Archive Backup Sets')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
return $isOnlyTrashed;
|
||||
})
|
||||
->modalDescription('This archives backup sets (soft delete). Backup sets referenced by restore runs will be skipped.')
|
||||
->form(function (Collection $records) {
|
||||
if ($records->count() >= 10) {
|
||||
return [
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'backup_set', 'delete', $ids, $count);
|
||||
|
||||
if ($count >= 10) {
|
||||
Notification::make()
|
||||
->title('Bulk archive started')
|
||||
->body("Archiving {$count} backup sets in the background. Check the progress bar in the bottom right corner.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
|
||||
BulkBackupSetDeleteJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkBackupSetDeleteJob::dispatchSync($run->id);
|
||||
}
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
BulkAction::make('bulk_restore')
|
||||
->label('Restore Backup Sets')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
|
||||
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'backup_set', 'restore', $ids, $count);
|
||||
|
||||
if ($count >= 10) {
|
||||
Notification::make()
|
||||
->title('Bulk restore started')
|
||||
->body("Restoring {$count} backup sets in the background. Check the progress bar in the bottom right corner.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
|
||||
BulkBackupSetRestoreJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkBackupSetRestoreJob::dispatchSync($run->id);
|
||||
}
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
BulkAction::make('bulk_force_delete')
|
||||
->label('Force Delete Backup Sets')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?")
|
||||
->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.')
|
||||
->form(function (Collection $records) {
|
||||
if ($records->count() >= 10) {
|
||||
return [
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'backup_set', 'force_delete', $ids, $count);
|
||||
|
||||
if ($count >= 10) {
|
||||
Notification::make()
|
||||
->title('Bulk force delete started')
|
||||
->body("Force deleting {$count} backup sets in the background. Check the progress bar in the bottom right corner.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
|
||||
BulkBackupSetForceDeleteJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkBackupSetForceDeleteJob::dispatchSync($run->id);
|
||||
}
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
|
||||
@ -331,7 +331,7 @@ public static function table(Table $table): Table
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
BulkAction::make('bulk_delete')
|
||||
->label('Delete Policies')
|
||||
->label('Ignore Policies')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
|
||||
@ -3,14 +3,20 @@
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource\Pages;
|
||||
use App\Jobs\BulkRestoreRunDeleteJob;
|
||||
use App\Jobs\BulkRestoreRunForceDeleteJob;
|
||||
use App\Jobs\BulkRestoreRunRestoreJob;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Forms;
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -18,7 +24,10 @@
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use UnitEnum;
|
||||
|
||||
class RestoreRunResource extends Resource
|
||||
@ -105,7 +114,11 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('requested_by')->label('Requested by'),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TrashedFilter::make(),
|
||||
TrashedFilter::make()
|
||||
->label('Archived')
|
||||
->placeholder('Active')
|
||||
->trueLabel('All')
|
||||
->falseLabel('Archived'),
|
||||
])
|
||||
->actions([
|
||||
Actions\ViewAction::make(),
|
||||
@ -117,6 +130,16 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->visible(fn (RestoreRun $record) => ! $record->trashed())
|
||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||
if (! $record->isDeletable()) {
|
||||
Notification::make()
|
||||
->title('Restore run cannot be archived')
|
||||
->body("Not deletable (status: {$record->status})")
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$record->delete();
|
||||
|
||||
if ($record->tenant) {
|
||||
@ -162,7 +185,158 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([]);
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
BulkAction::make('bulk_delete')
|
||||
->label('Archive Restore Runs')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
return $isOnlyTrashed;
|
||||
})
|
||||
->modalDescription('This archives restore runs (soft delete). Already archived runs will be skipped.')
|
||||
->form(function (Collection $records) {
|
||||
if ($records->count() >= 20) {
|
||||
return [
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, '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);
|
||||
}
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
BulkAction::make('bulk_restore')
|
||||
->label('Restore Restore Runs')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} restore runs?")
|
||||
->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.')
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, '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);
|
||||
}
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
BulkAction::make('bulk_force_delete')
|
||||
->label('Force Delete Restore Runs')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
|
||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} restore runs?")
|
||||
->modalDescription('This is permanent. Only archived restore runs will be permanently deleted; active runs will be skipped.')
|
||||
->form([
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
]),
|
||||
])
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, '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);
|
||||
}
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
|
||||
@ -97,10 +97,10 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TrashedFilter::make()
|
||||
->label('Archive filter')
|
||||
->placeholder('Active only')
|
||||
->trueLabel('Active + archived')
|
||||
->falseLabel('Archived only')
|
||||
->label('Archived')
|
||||
->placeholder('Active')
|
||||
->trueLabel('All')
|
||||
->falseLabel('Archived')
|
||||
->default(true),
|
||||
Tables\Filters\SelectFilter::make('app_status')
|
||||
->options([
|
||||
|
||||
158
app/Jobs/BulkBackupSetDeleteJob.php
Normal file
158
app/Jobs/BulkBackupSetDeleteJob.php
Normal file
@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Services\BulkOperationService;
|
||||
use Filament\Notifications\Notification;
|
||||
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 BulkBackupSetDeleteJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
) {}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $backupSetId) {
|
||||
$itemCount++;
|
||||
|
||||
try {
|
||||
/** @var BackupSet|null $backupSet */
|
||||
$backupSet = BackupSet::withTrashed()
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->whereKey($backupSetId)
|
||||
->first();
|
||||
|
||||
if (! $backupSet) {
|
||||
$service->recordFailure($run, (string) $backupSetId, 'Backup set not found');
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Archive Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($backupSet->trashed()) {
|
||||
$service->recordSkippedWithReason($run, (string) $backupSet->id, 'Already archived');
|
||||
$skipped++;
|
||||
$skipReasons['Already archived'] = ($skipReasons['Already archived'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($backupSet->restoreRuns()->withTrashed()->exists()) {
|
||||
$service->recordSkippedWithReason($run, (string) $backupSet->id, 'Referenced by restore runs');
|
||||
$skipped++;
|
||||
$skipReasons['Referenced by restore runs'] = ($skipReasons['Referenced by restore runs'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$backupSet->delete();
|
||||
$service->recordSuccess($run);
|
||||
$succeeded++;
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $backupSetId, $e->getMessage());
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Archive Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
|
||||
if (! $run->user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = "Archived {$succeeded} backup sets";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if (! empty($skipReasons)) {
|
||||
$summary = collect($skipReasons)
|
||||
->sortDesc()
|
||||
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||
->take(3)
|
||||
->implode(', ');
|
||||
|
||||
if ($summary !== '') {
|
||||
$message .= " Skip reasons: {$summary}.";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Archive Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
160
app/Jobs/BulkBackupSetForceDeleteJob.php
Normal file
160
app/Jobs/BulkBackupSetForceDeleteJob.php
Normal file
@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Services\BulkOperationService;
|
||||
use Filament\Notifications\Notification;
|
||||
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 BulkBackupSetForceDeleteJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
) {}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $backupSetId) {
|
||||
$itemCount++;
|
||||
|
||||
try {
|
||||
/** @var BackupSet|null $backupSet */
|
||||
$backupSet = BackupSet::withTrashed()
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->whereKey($backupSetId)
|
||||
->first();
|
||||
|
||||
if (! $backupSet) {
|
||||
$service->recordFailure($run, (string) $backupSetId, 'Backup set not found');
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $backupSet->trashed()) {
|
||||
$service->recordSkippedWithReason($run, (string) $backupSet->id, 'Not archived');
|
||||
$skipped++;
|
||||
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($backupSet->restoreRuns()->withTrashed()->exists()) {
|
||||
$service->recordSkippedWithReason($run, (string) $backupSet->id, 'Referenced by restore runs');
|
||||
$skipped++;
|
||||
$skipReasons['Referenced by restore runs'] = ($skipReasons['Referenced by restore runs'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$backupSet->items()->withTrashed()->forceDelete();
|
||||
$backupSet->forceDelete();
|
||||
|
||||
$service->recordSuccess($run);
|
||||
$succeeded++;
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $backupSetId, $e->getMessage());
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
152
app/Jobs/BulkBackupSetRestoreJob.php
Normal file
152
app/Jobs/BulkBackupSetRestoreJob.php
Normal file
@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Services\BulkOperationService;
|
||||
use Filament\Notifications\Notification;
|
||||
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 BulkBackupSetRestoreJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
) {}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $backupSetId) {
|
||||
$itemCount++;
|
||||
|
||||
try {
|
||||
/** @var BackupSet|null $backupSet */
|
||||
$backupSet = BackupSet::withTrashed()
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->whereKey($backupSetId)
|
||||
->first();
|
||||
|
||||
if (! $backupSet) {
|
||||
$service->recordFailure($run, (string) $backupSetId, 'Backup set not found');
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Restore Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $backupSet->trashed()) {
|
||||
$service->recordSkippedWithReason($run, (string) $backupSet->id, 'Not archived');
|
||||
$skipped++;
|
||||
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$backupSet->restore();
|
||||
$backupSet->items()->withTrashed()->restore();
|
||||
|
||||
$service->recordSuccess($run);
|
||||
$succeeded++;
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $backupSetId, $e->getMessage());
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Restore Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -44,7 +44,7 @@ public function handle(BulkOperationService $service): void
|
||||
$skipped = 0;
|
||||
$failures = [];
|
||||
|
||||
$chunkSize = 10;
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
@ -129,7 +129,7 @@ public function handle(BulkOperationService $service): void
|
||||
|
||||
}
|
||||
|
||||
// Refresh the run from database every 10 items to avoid stale data
|
||||
// Refresh the run from database every $chunkSize items to avoid stale data
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
|
||||
|
||||
@ -51,7 +51,7 @@ public function handle(BulkOperationService $service): void
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$failures = [];
|
||||
$chunkSize = 10;
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
|
||||
@ -31,7 +31,7 @@ public function handle(BulkOperationService $service, PolicySyncService $syncSer
|
||||
$service->start($run);
|
||||
|
||||
try {
|
||||
$chunkSize = 10;
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$itemCount = 0;
|
||||
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
|
||||
@ -35,7 +35,7 @@ public function handle(BulkOperationService $service): void
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
|
||||
$chunkSize = 10;
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
|
||||
foreach ($run->item_ids as $policyId) {
|
||||
$itemCount++;
|
||||
|
||||
@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
|
||||
$chunkSize = 10;
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
|
||||
|
||||
@ -38,7 +38,7 @@ public function handle(BulkOperationService $service): void
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
|
||||
$chunkSize = 10;
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
|
||||
$chunkSize = 10;
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
|
||||
|
||||
177
app/Jobs/BulkRestoreRunDeleteJob.php
Normal file
177
app/Jobs/BulkRestoreRunDeleteJob.php
Normal file
@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Services\BulkOperationService;
|
||||
use Filament\Notifications\Notification;
|
||||
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 BulkRestoreRunDeleteJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
) {}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
|
||||
try {
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $restoreRunId) {
|
||||
$itemCount++;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
150
app/Jobs/BulkRestoreRunForceDeleteJob.php
Normal file
150
app/Jobs/BulkRestoreRunForceDeleteJob.php
Normal file
@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Services\BulkOperationService;
|
||||
use Filament\Notifications\Notification;
|
||||
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 BulkRestoreRunForceDeleteJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
) {}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $restoreRunId) {
|
||||
$itemCount++;
|
||||
|
||||
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 Force 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, 'Not archived');
|
||||
$skipped++;
|
||||
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$restoreRun->forceDelete();
|
||||
$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 Force Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
|
||||
if (! $run->user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = "Force deleted {$succeeded} restore runs";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if (! empty($skipReasons)) {
|
||||
$summary = collect($skipReasons)
|
||||
->sortDesc()
|
||||
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||
->take(3)
|
||||
->implode(', ');
|
||||
|
||||
if ($summary !== '') {
|
||||
$message .= " Skip reasons: {$summary}.";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
150
app/Jobs/BulkRestoreRunRestoreJob.php
Normal file
150
app/Jobs/BulkRestoreRunRestoreJob.php
Normal file
@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Services\BulkOperationService;
|
||||
use Filament\Notifications\Notification;
|
||||
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 BulkRestoreRunRestoreJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
) {}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $restoreRunId) {
|
||||
$itemCount++;
|
||||
|
||||
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 Restore 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, 'Not archived');
|
||||
$skipped++;
|
||||
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$restoreRun->restore();
|
||||
$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 Restore Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
|
||||
if (! $run->user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = "Restored {$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 Restore Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@ -11,8 +11,11 @@ class BulkOperationProgress extends Component
|
||||
{
|
||||
public $runs;
|
||||
|
||||
public int $pollSeconds = 3;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->pollSeconds = max(1, min(10, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3)));
|
||||
$this->loadRuns();
|
||||
}
|
||||
|
||||
|
||||
@ -36,6 +36,14 @@ public function backupSet(): BelongsTo
|
||||
|
||||
public function scopeDeletable($query)
|
||||
{
|
||||
return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors']);
|
||||
return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed']);
|
||||
}
|
||||
|
||||
public function isDeletable(): bool
|
||||
{
|
||||
$status = strtolower(trim((string) $this->status));
|
||||
$status = str_replace([' ', '-'], '_', $status);
|
||||
|
||||
return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed'], true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,4 +118,9 @@
|
||||
'features' => [
|
||||
'conditional_access' => true,
|
||||
],
|
||||
|
||||
'bulk_operations' => [
|
||||
'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10),
|
||||
'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3),
|
||||
],
|
||||
];
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<div wire:poll.3s="loadRuns">
|
||||
<div wire:poll.{{ $pollSeconds }}s="loadRuns">
|
||||
<!-- Bulk Operation Progress Component: {{ $runs->count() }} active runs -->
|
||||
@if($runs->isNotEmpty())
|
||||
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
|
||||
|
||||
@ -120,6 +120,17 @@ ### Browser Tests (Pest v4)
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
These defaults are safe for staging/production, but can be tuned per environment.
|
||||
|
||||
- **Chunk size** (job refresh/progress cadence):
|
||||
- `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`)
|
||||
- **Progress polling interval** (UI updates):
|
||||
- `TENANTPILOT_BULK_POLL_INTERVAL_SECONDS` (default `3`, clamped to 1–10 seconds)
|
||||
- **Policy version prune retention window**:
|
||||
- Default `90` days (editable in the prune modal as “Retention Days”)
|
||||
|
||||
## Manual Testing Workflow
|
||||
|
||||
### Scenario 1: Bulk Delete Policies (< 20 items)
|
||||
|
||||
@ -113,7 +113,7 @@ ### Implementation for Bulk Sync Policies
|
||||
|
||||
- [x] T035c Add bulk sync action to PolicyResource in app/Filament/Resources/PolicyResource.php
|
||||
- [x] T035d Dispatch SyncPoliciesJob for selected policies (choose per-ID or batched IDs) in app/Jobs/SyncPoliciesJob.php (or a new dedicated bulk sync job)
|
||||
- [ ] T035e Manual QA: bulk sync 25 policies (verify queued processing + notifications)
|
||||
- [x] T035e Manual QA: bulk sync 25 policies (verify queued processing + notifications)
|
||||
|
||||
**Checkpoint**: Bulk sync action queues work and respects permissions
|
||||
|
||||
@ -133,9 +133,9 @@ ### Tests for User Story 5
|
||||
|
||||
### Validation for User Story 5
|
||||
|
||||
- [ ] T039 [US5] Manual test: Bulk delete 10 policies → confirm without typing (should work)
|
||||
- [ ] T040 [US5] Manual test: Bulk delete 25 policies → type "delete" → button stays disabled
|
||||
- [ ] T041 [US5] Manual test: Bulk delete 25 policies → type "DELETE" → button enables, operation proceeds
|
||||
- [x] T039 [US5] Manual test: Bulk delete 10 policies → confirm without typing (should work)
|
||||
- [x] T040 [US5] Manual test: Bulk delete 25 policies → type "delete" → button stays disabled
|
||||
- [x] T041 [US5] Manual test: Bulk delete 25 policies → type "DELETE" → button enables, operation proceeds
|
||||
- [x] T042 [US5] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkTypeToConfirmTest.php`
|
||||
|
||||
**Checkpoint**: Type-to-confirm working correctly for all thresholds
|
||||
@ -162,13 +162,13 @@ ### Implementation for User Story 6
|
||||
- [x] T049 [US6] Update BulkPolicyExportJob to emit progress after each chunk
|
||||
- [x] T050 [US6] Implement circuit breaker logic (abort if >50% fail) in jobs
|
||||
- [x] T051 [US6] Add progress polling to Filament notifications or sidebar widget
|
||||
- [ ] T052 [US6] Test progress with 100 policies (manual QA, observe updates)
|
||||
- [ ] T053 [US6] Test circuit breaker with mock failures (manual QA)
|
||||
- [x] T052 [US6] Test progress with 100 policies (manual QA, observe updates)
|
||||
- [x] T053 [US6] Test circuit breaker with mock failures (manual QA)
|
||||
- [x] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php`
|
||||
|
||||
- [ ] T054a [US6] Define/verify polling interval meets NFR-005.3 (≤10s updates) and document where configured
|
||||
- [ ] T054b [US6] Manual QA: force a catastrophic job failure and verify FR-005.13 notification behavior
|
||||
- [ ] T054c [US6] Manual QA: verify UI remains responsive during a 100-item queued run (NFR-005.4)
|
||||
- [x] T054a [US6] Define/verify polling interval meets NFR-005.3 (≤10s updates) and document where configured
|
||||
- [x] T054b [US6] Manual QA: force a catastrophic job failure and verify FR-005.13 notification behavior
|
||||
- [x] T054c [US6] Manual QA: verify UI remains responsive during a 100-item queued run (NFR-005.4)
|
||||
|
||||
**Checkpoint**: Progress tracking working, polling updates UI, circuit breaker aborts high-failure jobs
|
||||
|
||||
@ -194,7 +194,7 @@ ### Implementation for User Story 3
|
||||
- [x] T061 [US3] Implement eligibility check per version in job (reuse pruneEligible scope)
|
||||
- [x] T062 [US3] Collect skip reasons for ineligible versions
|
||||
- [x] T063 [US3] Add type-to-confirm for ≥20 versions
|
||||
- [ ] T064 [US3] Test prune with 30 versions (15 eligible, 15 ineligible) - manual QA
|
||||
- [x] T064 [US3] Test prune with 30 versions (15 eligible, 15 ineligible) - manual QA
|
||||
- [x] T065 [US3] Verify skip reasons in notification and audit log
|
||||
- [x] T066 [US3] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkPruneVersionsTest.php`
|
||||
|
||||
@ -218,20 +218,28 @@ ## Phase 8: User Story 4 - Bulk Delete Restore Runs (Priority: P2)
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [ ] T067 [P] [US4] Write unit test for deletable() scope in tests/Unit/RestoreRunDeletableTest.php
|
||||
- [ ] T068 [P] [US4] Write unit test for BulkRestoreRunDeleteJob in tests/Unit/BulkRestoreRunDeleteJobTest.php
|
||||
- [ ] T069 [P] [US4] Write feature test for bulk delete restore runs in tests/Feature/BulkDeleteRestoreRunsTest.php
|
||||
- [ ] T070 [P] [US4] Write test for mixed statuses (skip running) in tests/Feature/BulkDeleteMixedStatusTest.php
|
||||
- [x] T067 [P] [US4] Write unit test for deletable() scope in tests/Unit/RestoreRunDeletableTest.php
|
||||
- [x] T068 [P] [US4] Write unit test for BulkRestoreRunDeleteJob in tests/Unit/BulkRestoreRunDeleteJobTest.php
|
||||
- [x] T069 [P] [US4] Write feature test for bulk delete restore runs in tests/Feature/BulkDeleteRestoreRunsTest.php
|
||||
- [x] T070 [P] [US4] Write test for mixed statuses (skip running) in tests/Feature/BulkDeleteMixedStatusTest.php
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [ ] T071 [P] [US4] Create BulkRestoreRunDeleteJob in app/Jobs/BulkRestoreRunDeleteJob.php
|
||||
- [ ] T072 [US4] Add bulk delete action to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php
|
||||
- [ ] T073 [US4] Filter by deletable() scope (completed, failed, aborted only)
|
||||
- [ ] T074 [US4] Skip running restore runs with warning
|
||||
- [ ] T075 [US4] Add type-to-confirm for ≥20 runs
|
||||
- [ ] T076 [US4] Test delete with 20 completed + 5 running (manual QA, verify 5 skipped)
|
||||
- [ ] T077 [US4] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteRestoreRunsTest.php`
|
||||
- [x] T071 [P] [US4] Create BulkRestoreRunDeleteJob in app/Jobs/BulkRestoreRunDeleteJob.php
|
||||
- [x] T072 [US4] Add bulk delete action to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php
|
||||
- [x] T073 [US4] Filter by deletable() scope (completed, failed, aborted only)
|
||||
- [x] T074 [US4] Skip running restore runs with warning
|
||||
- [x] T075 [US4] Add type-to-confirm for ≥20 runs
|
||||
- [x] T076 [US4] Test delete with 20 completed + 5 running (manual QA, verify 5 skipped)
|
||||
- [x] T077 [US4] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteRestoreRunsTest.php`
|
||||
|
||||
- [x] T077a [US4] Add bulk force delete restore runs job in app/Jobs/BulkRestoreRunForceDeleteJob.php
|
||||
- [x] T077b [US4] Add bulk force delete action (archived-only) to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php
|
||||
- [x] T077c [US4] Write feature test for bulk force delete in tests/Feature/BulkForceDeleteRestoreRunsTest.php
|
||||
|
||||
- [x] T077d [US4] Add bulk restore restore runs job in app/Jobs/BulkRestoreRunRestoreJob.php
|
||||
- [x] T077e [US4] Add bulk restore action (archived-only) to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php
|
||||
- [x] T077f [US4] Write unit+feature tests for bulk restore in tests/Unit/BulkRestoreRunRestoreJobTest.php and tests/Feature/BulkRestoreRestoreRunsTest.php
|
||||
|
||||
**Checkpoint**: Restore runs bulk delete working, running runs protected, skip warnings shown
|
||||
|
||||
@ -245,17 +253,25 @@ ## Phase 9: Additional Resource - Bulk Delete Backup Sets (Priority: P2)
|
||||
|
||||
### Tests for Additional Resource
|
||||
|
||||
- [ ] T078 [P] Write unit test for BulkBackupSetDeleteJob in tests/Unit/BulkBackupSetDeleteJobTest.php
|
||||
- [ ] T079 [P] Write feature test for bulk delete backup sets in tests/Feature/BulkDeleteBackupSetsTest.php
|
||||
- [x] T078 [P] Write unit test for BulkBackupSetDeleteJob in tests/Unit/BulkBackupSetDeleteJobTest.php
|
||||
- [x] T079 [P] Write feature test for bulk delete backup sets in tests/Feature/BulkDeleteBackupSetsTest.php
|
||||
|
||||
### Implementation for Additional Resource
|
||||
|
||||
- [ ] T080 [P] Create BulkBackupSetDeleteJob in app/Jobs/BulkBackupSetDeleteJob.php
|
||||
- [ ] T081 Add bulk delete action to BackupSetResource in app/Filament/Resources/BackupSetResource.php
|
||||
- [ ] T082 Verify cascade-delete logic for backup items (should be automatic via foreign key)
|
||||
- [ ] T083 Add type-to-confirm for ≥10 sets
|
||||
- [ ] T084 Test delete with 15 backup sets (manual QA)
|
||||
- [ ] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php`
|
||||
- [x] T080 [P] Create BulkBackupSetDeleteJob in app/Jobs/BulkBackupSetDeleteJob.php
|
||||
- [x] T081 Add bulk delete action to BackupSetResource in app/Filament/Resources/BackupSetResource.php
|
||||
- [x] T082 Verify cascade-delete logic for backup items (should be automatic via foreign key)
|
||||
- [x] T083 Add type-to-confirm for ≥10 sets
|
||||
- [x] T084 Test delete with 15 backup sets (manual QA)
|
||||
- [x] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php`
|
||||
|
||||
- [x] T085e Add bulk restore backup sets job in app/Jobs/BulkBackupSetRestoreJob.php
|
||||
- [x] T085f Add bulk restore action (archived-only) to BackupSetResource in app/Filament/Resources/BackupSetResource.php
|
||||
- [x] T085g Write unit+feature tests for bulk restore in tests/Unit/BulkBackupSetRestoreJobTest.php and tests/Feature/BulkRestoreBackupSetsTest.php
|
||||
|
||||
- [x] T085h Add bulk force delete backup sets job in app/Jobs/BulkBackupSetForceDeleteJob.php
|
||||
- [x] T085i Add bulk force delete action (archived-only) to BackupSetResource in app/Filament/Resources/BackupSetResource.php
|
||||
- [x] T085j Write unit+feature tests for bulk force delete in tests/Unit/BulkBackupSetForceDeleteJobTest.php and tests/Feature/BulkForceDeleteBackupSetsTest.php
|
||||
|
||||
### Additional: Bulk Archive Backup Sets (FR-005.23)
|
||||
|
||||
@ -272,17 +288,17 @@ ## Phase 10: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Documentation, cleanup, performance optimization
|
||||
|
||||
- [ ] T086 [P] Update README.md with bulk operations feature description
|
||||
- [ ] T087 [P] Update quickstart.md with manual testing scenarios (already done in planning)
|
||||
- [x] T086 [P] Update README.md with bulk operations feature description
|
||||
- [x] T087 [P] Update quickstart.md with manual testing scenarios (already done in planning)
|
||||
- [ ] T088 Code cleanup: Remove debug statements, refactor duplicated logic
|
||||
- [ ] T089 Performance test: Bulk delete 500 policies (should complete <5 minutes)
|
||||
- [ ] T090 Load test: Concurrent bulk operations (2-3 admins, different resources)
|
||||
- [ ] T091 [P] Security review: Verify tenant isolation in all jobs
|
||||
- [ ] T092 [P] Permission audit: Verify all bulk actions respect RBAC
|
||||
- [ ] T093 Run full test suite: `./vendor/bin/sail artisan test`
|
||||
- [ ] T094 Run Pint formatting: `./vendor/bin/sail composer pint`
|
||||
- [ ] T095 Manual QA checklist: Complete all scenarios from quickstart.md
|
||||
- [ ] T096 Document configuration options (chunk size, polling interval, retention days)
|
||||
- [x] T093 Run full test suite: `./vendor/bin/sail artisan test`
|
||||
- [x] T094 Run Pint formatting: `./vendor/bin/sail composer pint`
|
||||
- [x] T095 Manual QA checklist: Complete all scenarios from quickstart.md
|
||||
- [x] T096 Document configuration options (chunk size, polling interval, retention days)
|
||||
- [ ] T097 Create BulkOperationRun resource page in Filament (view progress, retry failed)
|
||||
- [ ] T098 Add bulk operation metrics to dashboard (total runs, success rate)
|
||||
|
||||
|
||||
81
tests/Feature/BulkDeleteBackupSetsTest.php
Normal file
81
tests/Feature/BulkDeleteBackupSetsTest.php
Normal file
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('backup sets table bulk archive creates a run and archives selected sets', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$sets = collect(range(1, 3))->map(function (int $i) use ($tenant) {
|
||||
return BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup '.$i,
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
$sets->each(function (BackupSet $set) use ($tenant) {
|
||||
BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $set->id,
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'policy-'.$set->id,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10',
|
||||
'payload' => ['id' => 'policy-'.$set->id],
|
||||
'metadata' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BackupSetResource\Pages\ListBackupSets::class)
|
||||
->callTableBulkAction('bulk_delete', $sets)
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
$sets->each(fn (BackupSet $set) => expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue());
|
||||
|
||||
$bulkRun = BulkOperationRun::query()
|
||||
->where('resource', 'backup_set')
|
||||
->where('action', 'delete')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->status)->toBe('completed');
|
||||
});
|
||||
|
||||
test('backup sets table bulk archive requires type-to-confirm for 10+ sets', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$sets = collect(range(1, 10))->map(function (int $i) use ($tenant) {
|
||||
return BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup '.$i,
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
});
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BackupSetResource\Pages\ListBackupSets::class)
|
||||
->callTableBulkAction('bulk_delete', $sets)
|
||||
->assertHasTableBulkActionErrors();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BackupSetResource\Pages\ListBackupSets::class)
|
||||
->callTableBulkAction('bulk_delete', $sets, data: [
|
||||
'confirmation' => 'DELETE',
|
||||
])
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
});
|
||||
61
tests/Feature/BulkDeleteMixedStatusTest.php
Normal file
61
tests/Feature/BulkDeleteMixedStatusTest.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('bulk delete restore runs skips running items', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$completedRuns = collect(range(1, 3))->map(function () use ($tenant, $backupSet) {
|
||||
return RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'status' => 'completed',
|
||||
'is_dry_run' => true,
|
||||
'requested_by' => 'tester@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
$running = RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'status' => 'running',
|
||||
'is_dry_run' => true,
|
||||
'requested_by' => 'tester@example.com',
|
||||
]);
|
||||
|
||||
$records = $completedRuns->concat([$running]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(RestoreRunResource\Pages\ListRestoreRuns::class)
|
||||
->callTableBulkAction('bulk_delete', $records)
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
$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')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->skipped)->toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
50
tests/Feature/BulkDeleteRestoreRunsTest.php
Normal file
50
tests/Feature/BulkDeleteRestoreRunsTest.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('bulk delete restore runs soft deletes selected runs', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$runs = collect(range(1, 5))->map(function () use ($tenant, $backupSet) {
|
||||
return RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'status' => 'completed',
|
||||
'is_dry_run' => true,
|
||||
'requested_by' => 'tester@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(RestoreRunResource\Pages\ListRestoreRuns::class)
|
||||
->callTableBulkAction('bulk_delete', $runs)
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
$runs->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue());
|
||||
|
||||
$bulkRun = BulkOperationRun::query()
|
||||
->where('resource', 'restore_run')
|
||||
->where('action', 'delete')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->status)->toBe('completed');
|
||||
});
|
||||
55
tests/Feature/BulkForceDeleteBackupSetsTest.php
Normal file
55
tests/Feature/BulkForceDeleteBackupSetsTest.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('backup sets table bulk force delete permanently deletes archived sets and their items', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$set = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$item = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $set->id,
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'policy-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10',
|
||||
'payload' => ['id' => 'policy-1'],
|
||||
'metadata' => null,
|
||||
]);
|
||||
|
||||
$set->delete();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BackupSetResource\Pages\ListBackupSets::class)
|
||||
->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false)
|
||||
->callTableBulkAction('bulk_force_delete', collect([$set]))
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
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')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->status)->toBe('completed');
|
||||
});
|
||||
57
tests/Feature/BulkForceDeleteRestoreRunsTest.php
Normal file
57
tests/Feature/BulkForceDeleteRestoreRunsTest.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('bulk force delete restore runs permanently deletes archived runs', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$runs = collect(range(1, 3))->map(function () use ($tenant, $backupSet) {
|
||||
$run = RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'status' => 'completed',
|
||||
'is_dry_run' => true,
|
||||
'requested_by' => 'tester@example.com',
|
||||
]);
|
||||
|
||||
$run->delete();
|
||||
|
||||
return $run;
|
||||
});
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(RestoreRunResource\Pages\ListRestoreRuns::class)
|
||||
->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false)
|
||||
->callTableBulkAction('bulk_force_delete', $runs, data: [
|
||||
'confirmation' => 'DELETE',
|
||||
])
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
$runs->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id))->toBeNull());
|
||||
|
||||
$bulkRun = BulkOperationRun::query()
|
||||
->where('resource', 'restore_run')
|
||||
->where('action', 'force_delete')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->status)->toBe('completed');
|
||||
});
|
||||
58
tests/Feature/BulkRestoreBackupSetsTest.php
Normal file
58
tests/Feature/BulkRestoreBackupSetsTest.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('backup sets table bulk restore restores archived sets and their items', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$set = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$item = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $set->id,
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'policy-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10',
|
||||
'payload' => ['id' => 'policy-1'],
|
||||
'metadata' => null,
|
||||
]);
|
||||
|
||||
$set->delete();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BackupSetResource\Pages\ListBackupSets::class)
|
||||
->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false)
|
||||
->callTableBulkAction('bulk_restore', collect([$set]))
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
$set->refresh();
|
||||
$item->refresh();
|
||||
|
||||
expect($set->trashed())->toBeFalse();
|
||||
expect($item->trashed())->toBeFalse();
|
||||
|
||||
$bulkRun = BulkOperationRun::query()
|
||||
->where('resource', 'backup_set')
|
||||
->where('action', 'restore')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->status)->toBe('completed');
|
||||
});
|
||||
57
tests/Feature/BulkRestoreRestoreRunsTest.php
Normal file
57
tests/Feature/BulkRestoreRestoreRunsTest.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('restore runs table bulk restore creates a run and restores archived records', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$run = RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'status' => 'completed',
|
||||
'is_dry_run' => true,
|
||||
'requested_by' => 'tester@example.com',
|
||||
]);
|
||||
|
||||
$run->delete();
|
||||
expect($run->trashed())->toBeTrue();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(RestoreRunResource\Pages\ListRestoreRuns::class)
|
||||
->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false)
|
||||
->callTableBulkAction('bulk_restore', collect([$run]))
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
$bulkRun = BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('resource', 'restore_run')
|
||||
->where('action', 'restore')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->succeeded)->toBe(1)
|
||||
->and($bulkRun->skipped)->toBe(0)
|
||||
->and($bulkRun->failed)->toBe(0);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->trashed())->toBeFalse();
|
||||
});
|
||||
@ -166,6 +166,8 @@
|
||||
'name' => 'Tenant 3',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'pol-1',
|
||||
@ -201,6 +203,8 @@
|
||||
'name' => 'Tenant 3b',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'pol-1b',
|
||||
|
||||
37
tests/Feature/RestoreRunArchiveGuardTest.php
Normal file
37
tests/Feature/RestoreRunArchiveGuardTest.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('restore run archive action does not archive non-deletable runs', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set RR',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$running = RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'status' => 'running',
|
||||
'is_dry_run' => true,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListRestoreRuns::class)
|
||||
->callTableAction('archive', $running);
|
||||
|
||||
expect(RestoreRun::withTrashed()->find($running->id)?->trashed())->toBeFalse();
|
||||
});
|
||||
91
tests/Unit/BulkBackupSetDeleteJobTest.php
Normal file
91
tests/Unit/BulkBackupSetDeleteJobTest.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\BulkBackupSetDeleteJob;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
test('bulk backup set delete job archives sets and cascades to backup items', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$set = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 2,
|
||||
]);
|
||||
|
||||
$items = collect(range(1, 2))->map(function (int $i) use ($tenant, $set) {
|
||||
return BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $set->id,
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'policy-'.$i,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10',
|
||||
'payload' => ['id' => 'policy-'.$i],
|
||||
'metadata' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'backup_set', 'delete', [$set->id], 1);
|
||||
|
||||
(new BulkBackupSetDeleteJob($run->id))->handle($service);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed')
|
||||
->and($run->processed_items)->toBe(1)
|
||||
->and($run->succeeded)->toBe(1)
|
||||
->and($run->failed)->toBe(0)
|
||||
->and($run->skipped)->toBe(0);
|
||||
|
||||
expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue();
|
||||
|
||||
$items->each(function (BackupItem $item) {
|
||||
expect(BackupItem::withTrashed()->find($item->id)?->trashed())->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
test('bulk backup set delete job skips sets referenced by restore runs', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$set = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $set->id,
|
||||
'status' => 'completed',
|
||||
'is_dry_run' => true,
|
||||
'requested_by' => 'tester@example.com',
|
||||
]);
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'backup_set', 'delete', [$set->id], 1);
|
||||
|
||||
(new BulkBackupSetDeleteJob($run->id))->handle($service);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed')
|
||||
->and($run->processed_items)->toBe(1)
|
||||
->and($run->succeeded)->toBe(0)
|
||||
->and($run->failed)->toBe(0)
|
||||
->and($run->skipped)->toBe(1);
|
||||
|
||||
expect(collect($run->failures)->pluck('reason')->join(' '))->toContain('restore runs');
|
||||
expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeFalse();
|
||||
});
|
||||
107
tests/Unit/BulkBackupSetForceDeleteJobTest.php
Normal file
107
tests/Unit/BulkBackupSetForceDeleteJobTest.php
Normal file
@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\BulkBackupSetForceDeleteJob;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
test('bulk backup set force delete job permanently deletes archived sets and their items', function () {
|
||||
$tenant = Tenant::factory()->create(['is_current' => true]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$set = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$item = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $set->id,
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'policy-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10',
|
||||
'payload' => ['id' => 'policy-1'],
|
||||
'metadata' => null,
|
||||
]);
|
||||
|
||||
$set->delete();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = BulkOperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'resource' => 'backup_set',
|
||||
'action' => 'force_delete',
|
||||
'status' => 'pending',
|
||||
'total_items' => 1,
|
||||
'item_ids' => [$set->id],
|
||||
'failures' => [],
|
||||
]);
|
||||
|
||||
(new BulkBackupSetForceDeleteJob($run->id))->handle($service);
|
||||
|
||||
expect(BackupSet::withTrashed()->find($set->id))->toBeNull();
|
||||
expect(BackupItem::withTrashed()->find($item->id))->toBeNull();
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed')
|
||||
->and($run->succeeded)->toBe(1)
|
||||
->and($run->skipped)->toBe(0)
|
||||
->and($run->failed)->toBe(0);
|
||||
});
|
||||
|
||||
test('bulk backup set force delete job skips sets referenced by restore runs', function () {
|
||||
$tenant = Tenant::factory()->create(['is_current' => true]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$set = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$set->delete();
|
||||
|
||||
RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $set->id,
|
||||
'status' => 'completed',
|
||||
'is_dry_run' => true,
|
||||
'requested_by' => 'tester@example.com',
|
||||
]);
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = BulkOperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'resource' => 'backup_set',
|
||||
'action' => 'force_delete',
|
||||
'status' => 'pending',
|
||||
'total_items' => 1,
|
||||
'item_ids' => [$set->id],
|
||||
'failures' => [],
|
||||
]);
|
||||
|
||||
(new BulkBackupSetForceDeleteJob($run->id))->handle($service);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed')
|
||||
->and($run->succeeded)->toBe(0)
|
||||
->and($run->skipped)->toBe(1)
|
||||
->and($run->failed)->toBe(0);
|
||||
|
||||
expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue();
|
||||
expect(collect($run->failures)->pluck('reason')->all())->toContain('Referenced by restore runs');
|
||||
});
|
||||
105
tests/Unit/BulkBackupSetRestoreJobTest.php
Normal file
105
tests/Unit/BulkBackupSetRestoreJobTest.php
Normal file
@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\BulkBackupSetRestoreJob;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
test('bulk backup set restore job restores archived sets and their items', function () {
|
||||
$tenant = Tenant::factory()->create(['is_current' => true]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$set = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$item = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $set->id,
|
||||
'policy_id' => null,
|
||||
'policy_identifier' => 'policy-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10',
|
||||
'payload' => ['id' => 'policy-1'],
|
||||
'metadata' => null,
|
||||
]);
|
||||
|
||||
$set->delete();
|
||||
|
||||
$set->refresh();
|
||||
expect($set->trashed())->toBeTrue();
|
||||
expect(BackupItem::withTrashed()->find($item->id)?->trashed())->toBeTrue();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = BulkOperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'resource' => 'backup_set',
|
||||
'action' => 'restore',
|
||||
'status' => 'pending',
|
||||
'total_items' => 1,
|
||||
'item_ids' => [$set->id],
|
||||
'failures' => [],
|
||||
]);
|
||||
|
||||
(new BulkBackupSetRestoreJob($run->id))->handle($service);
|
||||
|
||||
$set->refresh();
|
||||
expect($set->trashed())->toBeFalse();
|
||||
|
||||
$item->refresh();
|
||||
expect($item->trashed())->toBeFalse();
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed')
|
||||
->and($run->succeeded)->toBe(1)
|
||||
->and($run->skipped)->toBe(0)
|
||||
->and($run->failed)->toBe(0);
|
||||
});
|
||||
|
||||
test('bulk backup set restore job skips active sets', function () {
|
||||
$tenant = Tenant::factory()->create(['is_current' => true]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$set = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = BulkOperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'resource' => 'backup_set',
|
||||
'action' => 'restore',
|
||||
'status' => 'pending',
|
||||
'total_items' => 1,
|
||||
'item_ids' => [$set->id],
|
||||
'failures' => [],
|
||||
]);
|
||||
|
||||
(new BulkBackupSetRestoreJob($run->id))->handle($service);
|
||||
|
||||
$set->refresh();
|
||||
expect($set->trashed())->toBeFalse();
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed')
|
||||
->and($run->succeeded)->toBe(0)
|
||||
->and($run->skipped)->toBe(1)
|
||||
->and($run->failed)->toBe(0);
|
||||
|
||||
expect(collect($run->failures)->pluck('reason')->all())->toContain('Not archived');
|
||||
});
|
||||
94
tests/Unit/BulkRestoreRunDeleteJobTest.php
Normal file
94
tests/Unit/BulkRestoreRunDeleteJobTest.php
Normal file
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\BulkRestoreRunDeleteJob;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
test('job soft deletes deletable restore runs', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$completed = RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'status' => 'completed',
|
||||
'is_dry_run' => true,
|
||||
'requested_by' => 'tester@example.com',
|
||||
]);
|
||||
|
||||
$failed = RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'status' => 'failed',
|
||||
'is_dry_run' => true,
|
||||
'requested_by' => 'tester@example.com',
|
||||
]);
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'restore_run', 'delete', [$completed->id, $failed->id], 2);
|
||||
|
||||
$job = new BulkRestoreRunDeleteJob($run->id);
|
||||
$job->handle($service);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed')
|
||||
->and($run->processed_items)->toBe(2)
|
||||
->and($run->succeeded)->toBe(2)
|
||||
->and($run->failed)->toBe(0)
|
||||
->and($run->skipped)->toBe(0);
|
||||
|
||||
expect(RestoreRun::withTrashed()->find($completed->id)?->trashed())->toBeTrue();
|
||||
expect(RestoreRun::withTrashed()->find($failed->id)?->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
test('job skips non-deletable restore runs and records skip reasons', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$running = RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'status' => 'running',
|
||||
'is_dry_run' => true,
|
||||
'requested_by' => 'tester@example.com',
|
||||
]);
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'restore_run', 'delete', [$running->id], 1);
|
||||
|
||||
$job = new BulkRestoreRunDeleteJob($run->id);
|
||||
$job->handle($service);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed')
|
||||
->and($run->processed_items)->toBe(1)
|
||||
->and($run->succeeded)->toBe(0)
|
||||
->and($run->failed)->toBe(0)
|
||||
->and($run->skipped)->toBe(1);
|
||||
|
||||
expect($run->failures[0]['type'] ?? null)->toBe('skipped');
|
||||
expect($run->failures[0]['reason'] ?? '')->toContain('Not deletable');
|
||||
|
||||
expect(RestoreRun::withTrashed()->find($running->id)?->trashed())->toBeFalse();
|
||||
});
|
||||
102
tests/Unit/BulkRestoreRunRestoreJobTest.php
Normal file
102
tests/Unit/BulkRestoreRunRestoreJobTest.php
Normal file
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\BulkRestoreRunRestoreJob;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
test('bulk restore run restore restores archived runs', function () {
|
||||
$tenant = Tenant::factory()->create(['is_current' => true]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$restoreRun = RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'status' => 'completed',
|
||||
'is_dry_run' => true,
|
||||
'requested_by' => 'tester@example.com',
|
||||
]);
|
||||
|
||||
$restoreRun->delete();
|
||||
expect($restoreRun->trashed())->toBeTrue();
|
||||
|
||||
$run = BulkOperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'resource' => 'restore_run',
|
||||
'action' => 'restore',
|
||||
'status' => 'pending',
|
||||
'total_items' => 1,
|
||||
'item_ids' => [$restoreRun->id],
|
||||
'failures' => [],
|
||||
]);
|
||||
|
||||
(new BulkRestoreRunRestoreJob($run->id))->handle(app(BulkOperationService::class));
|
||||
|
||||
$restoreRun->refresh();
|
||||
expect($restoreRun->trashed())->toBeFalse();
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed')
|
||||
->and($run->succeeded)->toBe(1)
|
||||
->and($run->skipped)->toBe(0)
|
||||
->and($run->failed)->toBe(0);
|
||||
});
|
||||
|
||||
test('bulk restore run restore skips active runs', function () {
|
||||
$tenant = Tenant::factory()->create(['is_current' => true]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$restoreRun = RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'status' => 'completed',
|
||||
'is_dry_run' => true,
|
||||
'requested_by' => 'tester@example.com',
|
||||
]);
|
||||
|
||||
$run = BulkOperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'resource' => 'restore_run',
|
||||
'action' => 'restore',
|
||||
'status' => 'pending',
|
||||
'total_items' => 1,
|
||||
'item_ids' => [$restoreRun->id],
|
||||
'failures' => [],
|
||||
]);
|
||||
|
||||
(new BulkRestoreRunRestoreJob($run->id))->handle(app(BulkOperationService::class));
|
||||
|
||||
$restoreRun->refresh();
|
||||
expect($restoreRun->trashed())->toBeFalse();
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed')
|
||||
->and($run->succeeded)->toBe(0)
|
||||
->and($run->skipped)->toBe(1)
|
||||
->and($run->failed)->toBe(0);
|
||||
|
||||
expect(collect($run->failures)->pluck('reason')->all())->toContain('Not archived');
|
||||
});
|
||||
88
tests/Unit/RestoreRunDeletableTest.php
Normal file
88
tests/Unit/RestoreRunDeletableTest.php
Normal file
@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
test('deletable scope includes only finished statuses', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$statuses = [
|
||||
'completed',
|
||||
'failed',
|
||||
'aborted',
|
||||
'completed_with_errors',
|
||||
'partial',
|
||||
'previewed',
|
||||
'running',
|
||||
'pending',
|
||||
];
|
||||
|
||||
foreach ($statuses as $status) {
|
||||
RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'status' => $status,
|
||||
'is_dry_run' => true,
|
||||
'requested_by' => 'tester@example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
$deletableStatuses = RestoreRun::query()
|
||||
->deletable()
|
||||
->pluck('status')
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($deletableStatuses)->toBe([
|
||||
'aborted',
|
||||
'completed',
|
||||
'completed_with_errors',
|
||||
'failed',
|
||||
'partial',
|
||||
'previewed',
|
||||
]);
|
||||
});
|
||||
|
||||
test('isDeletable accepts partial even if status casing/format differs', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$partial = RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'status' => 'Partial',
|
||||
'is_dry_run' => true,
|
||||
'requested_by' => 'tester@example.com',
|
||||
]);
|
||||
|
||||
$completedWithErrors = RestoreRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'status' => 'completed-with-errors',
|
||||
'is_dry_run' => true,
|
||||
'requested_by' => 'tester@example.com',
|
||||
]);
|
||||
|
||||
expect($partial->isDeletable())->toBeTrue();
|
||||
expect($completedWithErrors->isDeletable())->toBeTrue();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user