feat/005-bulk-operations (#5)
## Summary <!-- Kurz: Was ändert sich und warum? --> ## Spec-Driven Development (SDD) - [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/` - [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md` - [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation) - [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert ## Implementation - [ ] Implementierung entspricht der Spec - [ ] Edge cases / Fehlerfälle berücksichtigt - [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes ## Tests - [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit) - [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`) ## Migration / Config / Ops (falls relevant) - [ ] Migration(en) enthalten und getestet - [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration) - [ ] Neue Env Vars dokumentiert (`.env.example` / Doku) - [ ] Queue/cron/storage Auswirkungen geprüft ## UI (Filament/Livewire) (falls relevant) - [ ] UI-Flows geprüft - [ ] Screenshots/Notizen hinzugefügt ## Notes <!-- Links, Screenshots, Follow-ups, offene Punkte --> Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #5
This commit is contained in:
parent
f4cf1dce6e
commit
d62c8825a1
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -3,6 +3,8 @@ # TenantAtlas Development Guidelines
|
|||||||
Auto-generated from all feature plans. Last updated: 2025-12-22
|
Auto-generated from all feature plans. Last updated: 2025-12-22
|
||||||
|
|
||||||
## Active Technologies
|
## Active Technologies
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
|
||||||
|
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -22,6 +24,7 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- feat/005-bulk-operations: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
|
||||||
|
|
||||||
- feat/005-bulk-operations: Added PHP 8.4.15
|
- feat/005-bulk-operations: Added PHP 8.4.15
|
||||||
|
|
||||||
|
|||||||
11
README.md
11
README.md
@ -29,6 +29,17 @@ ## TenantPilot setup
|
|||||||
- Ensure queue workers are running for jobs (e.g., policy sync) after deploy.
|
- Ensure queue workers are running for jobs (e.g., policy sync) after deploy.
|
||||||
- Keep secrets/env in Dokploy, never in code.
|
- 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
|
## 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`.
|
- 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\Pages;
|
||||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
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\BackupSet;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\BackupService;
|
use App\Services\Intune\BackupService;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Actions\BulkAction;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Infolists;
|
use Filament\Infolists;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class BackupSetResource extends Resource
|
class BackupSetResource extends Resource
|
||||||
@ -51,13 +60,43 @@ public static function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('created_at')->dateTime()->since(),
|
Tables\Columns\TextColumn::make('created_at')->dateTime()->since(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\TrashedFilter::make(),
|
Tables\Filters\TrashedFilter::make()
|
||||||
|
->label('Archived')
|
||||||
|
->placeholder('Active')
|
||||||
|
->trueLabel('All')
|
||||||
|
->falseLabel('Archived'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make()
|
Actions\ViewAction::make()
|
||||||
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
|
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
|
||||||
->openUrlInNewTab(false),
|
->openUrlInNewTab(false),
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
|
Actions\Action::make('restore')
|
||||||
|
->label('Restore')
|
||||||
|
->color('success')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (BackupSet $record) => $record->trashed())
|
||||||
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
|
$record->restore();
|
||||||
|
$record->items()->withTrashed()->restore();
|
||||||
|
|
||||||
|
if ($record->tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $record->tenant,
|
||||||
|
action: 'backup.restored',
|
||||||
|
resourceType: 'backup_set',
|
||||||
|
resourceId: (string) $record->id,
|
||||||
|
status: 'success',
|
||||||
|
context: ['metadata' => ['name' => $record->name]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Backup set restored')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
Actions\Action::make('archive')
|
Actions\Action::make('archive')
|
||||||
->label('Archive')
|
->label('Archive')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
@ -65,16 +104,6 @@ public static function table(Table $table): Table
|
|||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (BackupSet $record) => ! $record->trashed())
|
->visible(fn (BackupSet $record) => ! $record->trashed())
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Cannot archive backup set')
|
|
||||||
->body('Backup sets used by restore runs cannot be archived.')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->delete();
|
$record->delete();
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
@ -131,7 +160,164 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->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). Already archived 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', '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
|
public static function infolist(Schema $schema): Schema
|
||||||
|
|||||||
@ -4,21 +4,33 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\PolicyResource\Pages;
|
use App\Filament\Resources\PolicyResource\Pages;
|
||||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||||
|
use App\Jobs\BulkPolicyDeleteJob;
|
||||||
|
use App\Jobs\BulkPolicyExportJob;
|
||||||
|
use App\Jobs\BulkPolicySyncJob;
|
||||||
|
use App\Jobs\BulkPolicyUnignoreJob;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Intune\PolicyNormalizer;
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Actions\BulkAction;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Forms;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Tabs;
|
use Filament\Schemas\Components\Tabs;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class PolicyResource extends Resource
|
class PolicyResource extends Resource
|
||||||
@ -258,6 +270,30 @@ public static function table(Table $table): Table
|
|||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('visibility')
|
||||||
|
->label('Visibility')
|
||||||
|
->options([
|
||||||
|
'active' => 'Active',
|
||||||
|
'ignored' => 'Ignored',
|
||||||
|
])
|
||||||
|
->default('active')
|
||||||
|
->query(function (Builder $query, array $data) {
|
||||||
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
|
if (blank($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === 'active') {
|
||||||
|
$query->whereNull('ignored_at');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === 'ignored') {
|
||||||
|
$query->whereNotNull('ignored_at');
|
||||||
|
}
|
||||||
|
}),
|
||||||
Tables\Filters\SelectFilter::make('policy_type')
|
Tables\Filters\SelectFilter::make('policy_type')
|
||||||
->options(function () {
|
->options(function () {
|
||||||
return collect(config('tenantpilot.supported_policy_types', []))
|
return collect(config('tenantpilot.supported_policy_types', []))
|
||||||
@ -288,12 +324,248 @@ public static function table(Table $table): Table
|
|||||||
$query->whereIn('policy_type', $types);
|
$query->whereIn('policy_type', $types);
|
||||||
}),
|
}),
|
||||||
Tables\Filters\SelectFilter::make('platform')
|
Tables\Filters\SelectFilter::make('platform')
|
||||||
->options(fn () => Policy::query()->distinct()->pluck('platform', 'platform')->filter()->all()),
|
->options(fn () => Policy::query()
|
||||||
|
->distinct()
|
||||||
|
->pluck('platform', 'platform')
|
||||||
|
->filter()
|
||||||
|
->reject(fn ($platform) => is_string($platform) && strtolower($platform) === 'all')
|
||||||
|
->all()),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
|
ActionGroup::make([
|
||||||
|
Actions\Action::make('ignore')
|
||||||
|
->label('Ignore')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Policy $record) => $record->ignored_at === null)
|
||||||
|
->action(function (Policy $record) {
|
||||||
|
$record->ignore();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy ignored')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Actions\Action::make('restore')
|
||||||
|
->label('Restore')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Policy $record) => $record->ignored_at !== null)
|
||||||
|
->action(function (Policy $record) {
|
||||||
|
$record->unignore();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy restored')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Actions\Action::make('sync')
|
||||||
|
->label('Sync')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Policy $record) => $record->ignored_at === null)
|
||||||
|
->action(function (Policy $record) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'sync', [$record->id], 1);
|
||||||
|
|
||||||
|
BulkPolicySyncJob::dispatchSync($run->id);
|
||||||
|
}),
|
||||||
|
Actions\Action::make('export')
|
||||||
|
->label('Export to Backup')
|
||||||
|
->icon('heroicon-o-archive-box-arrow-down')
|
||||||
|
->visible(fn (Policy $record) => $record->ignored_at === null)
|
||||||
|
->form([
|
||||||
|
Forms\Components\TextInput::make('backup_name')
|
||||||
|
->label('Backup Name')
|
||||||
|
->required()
|
||||||
|
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||||
])
|
])
|
||||||
->bulkActions([]);
|
->action(function (Policy $record, array $data) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'export', [$record->id], 1);
|
||||||
|
|
||||||
|
BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']);
|
||||||
|
}),
|
||||||
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
BulkAction::make('bulk_delete')
|
||||||
|
->label('Ignore Policies')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
||||||
|
$value = $visibilityFilterState['value'] ?? null;
|
||||||
|
|
||||||
|
return $value === 'ignored';
|
||||||
|
})
|
||||||
|
->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, array $data) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'delete', $ids, $count);
|
||||||
|
|
||||||
|
if ($count >= 20) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk delete started')
|
||||||
|
->body("Deleting {$count} policies in the background. Check the progress bar in the bottom right corner.")
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->iconColor('warning')
|
||||||
|
->info()
|
||||||
|
->duration(8000)
|
||||||
|
->sendToDatabase($user)
|
||||||
|
->send();
|
||||||
|
|
||||||
|
BulkPolicyDeleteJob::dispatch($run->id);
|
||||||
|
} else {
|
||||||
|
BulkPolicyDeleteJob::dispatchSync($run->id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
|
||||||
|
BulkAction::make('bulk_restore')
|
||||||
|
->label('Restore Policies')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
||||||
|
$value = $visibilityFilterState['value'] ?? null;
|
||||||
|
|
||||||
|
return ! in_array($value, [null, 'ignored'], true);
|
||||||
|
})
|
||||||
|
->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, 'policy', 'unignore', $ids, $count);
|
||||||
|
|
||||||
|
if ($count >= 20) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk restore started')
|
||||||
|
->body("Restoring {$count} policies in the background. Check the progress bar in the bottom right corner.")
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->iconColor('warning')
|
||||||
|
->info()
|
||||||
|
->duration(8000)
|
||||||
|
->sendToDatabase($user)
|
||||||
|
->send();
|
||||||
|
|
||||||
|
BulkPolicyUnignoreJob::dispatch($run->id);
|
||||||
|
} else {
|
||||||
|
BulkPolicyUnignoreJob::dispatchSync($run->id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
|
||||||
|
BulkAction::make('bulk_sync')
|
||||||
|
->label('Sync Policies')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
||||||
|
$value = $visibilityFilterState['value'] ?? null;
|
||||||
|
|
||||||
|
return $value === 'ignored';
|
||||||
|
})
|
||||||
|
->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, 'policy', 'sync', $ids, $count);
|
||||||
|
|
||||||
|
if ($count >= 20) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk sync started')
|
||||||
|
->body("Syncing {$count} policies in the background. Check the progress bar in the bottom right corner.")
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->iconColor('warning')
|
||||||
|
->info()
|
||||||
|
->duration(8000)
|
||||||
|
->sendToDatabase($user)
|
||||||
|
->send();
|
||||||
|
|
||||||
|
BulkPolicySyncJob::dispatch($run->id);
|
||||||
|
} else {
|
||||||
|
BulkPolicySyncJob::dispatchSync($run->id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
|
||||||
|
BulkAction::make('bulk_export')
|
||||||
|
->label('Export to Backup')
|
||||||
|
->icon('heroicon-o-archive-box-arrow-down')
|
||||||
|
->form([
|
||||||
|
Forms\Components\TextInput::make('backup_name')
|
||||||
|
->label('Backup Name')
|
||||||
|
->required()
|
||||||
|
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||||
|
])
|
||||||
|
->action(function (Collection $records, array $data) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'export', $ids, $count);
|
||||||
|
|
||||||
|
if ($count >= 20) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk export started')
|
||||||
|
->body("Exporting {$count} policies to backup '{$data['backup_name']}' 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();
|
||||||
|
|
||||||
|
BulkPolicyExportJob::dispatch($run->id, $data['backup_name']);
|
||||||
|
} else {
|
||||||
|
BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
|
|||||||
@ -34,12 +34,14 @@ protected function getHeaderActions(): array
|
|||||||
->title('Policy sync completed')
|
->title('Policy sync completed')
|
||||||
->body(count($synced).' policies synced')
|
->body(count($synced).' policies synced')
|
||||||
->success()
|
->success()
|
||||||
|
->sendToDatabase(auth()->user())
|
||||||
->send();
|
->send();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy sync failed')
|
->title('Policy sync failed')
|
||||||
->body($e->getMessage())
|
->body($e->getMessage())
|
||||||
->danger()
|
->danger()
|
||||||
|
->sendToDatabase(auth()->user())
|
||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -3,12 +3,20 @@
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyVersionResource\Pages;
|
use App\Filament\Resources\PolicyVersionResource\Pages;
|
||||||
|
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
||||||
|
use App\Jobs\BulkPolicyVersionPruneJob;
|
||||||
|
use App\Jobs\BulkPolicyVersionRestoreJob;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\PolicyNormalizer;
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
use App\Services\Intune\VersionDiff;
|
use App\Services\Intune\VersionDiff;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
|
use Filament\Actions\BulkAction;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Forms;
|
||||||
use Filament\Infolists;
|
use Filament\Infolists;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
@ -16,7 +24,11 @@
|
|||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class PolicyVersionResource extends Resource
|
class PolicyVersionResource extends Resource
|
||||||
@ -112,7 +124,11 @@ public static function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\TrashedFilter::make(),
|
TrashedFilter::make()
|
||||||
|
->label('Archived')
|
||||||
|
->placeholder('Active')
|
||||||
|
->trueLabel('All')
|
||||||
|
->falseLabel('Archived'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make()
|
Actions\ViewAction::make()
|
||||||
@ -169,9 +185,205 @@ public static function table(Table $table): Table
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
Actions\Action::make('restore')
|
||||||
|
->label('Restore')
|
||||||
|
->color('success')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (PolicyVersion $record) => $record->trashed())
|
||||||
|
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
||||||
|
$record->restore();
|
||||||
|
|
||||||
|
if ($record->tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $record->tenant,
|
||||||
|
action: 'policy_version.restored',
|
||||||
|
resourceType: 'policy_version',
|
||||||
|
resourceId: (string) $record->id,
|
||||||
|
status: 'success',
|
||||||
|
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy version restored')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([]);
|
->bulkActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
BulkAction::make('bulk_prune_versions')
|
||||||
|
->label('Prune Versions')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
|
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||||
|
|
||||||
|
return $isOnlyTrashed;
|
||||||
|
})
|
||||||
|
->form(function (Collection $records) {
|
||||||
|
$fields = [
|
||||||
|
Forms\Components\TextInput::make('retention_days')
|
||||||
|
->label('Retention Days')
|
||||||
|
->helperText('Versions captured within the last N days will be skipped.')
|
||||||
|
->numeric()
|
||||||
|
->required()
|
||||||
|
->default(90)
|
||||||
|
->minValue(1),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($records->count() >= 20) {
|
||||||
|
$fields[] = Forms\Components\TextInput::make('confirmation')
|
||||||
|
->label('Type DELETE to confirm')
|
||||||
|
->required()
|
||||||
|
->in(['DELETE'])
|
||||||
|
->validationMessages([
|
||||||
|
'in' => 'Please type DELETE to confirm.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
})
|
||||||
|
->action(function (Collection $records, array $data) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
|
$retentionDays = (int) ($data['retention_days'] ?? 90);
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy_version', 'prune', $ids, $count);
|
||||||
|
|
||||||
|
if ($count >= 20) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk prune started')
|
||||||
|
->body("Pruning {$count} policy versions 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();
|
||||||
|
|
||||||
|
BulkPolicyVersionPruneJob::dispatch($run->id, $retentionDays);
|
||||||
|
} else {
|
||||||
|
BulkPolicyVersionPruneJob::dispatchSync($run->id, $retentionDays);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
|
||||||
|
BulkAction::make('bulk_restore_versions')
|
||||||
|
->label('Restore Versions')
|
||||||
|
->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()} policy versions?")
|
||||||
|
->modalDescription('Archived versions will be restored back to the active list. Active versions 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, 'policy_version', 'restore', $ids, $count);
|
||||||
|
|
||||||
|
if ($count >= 20) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk restore started')
|
||||||
|
->body("Restoring {$count} policy versions 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();
|
||||||
|
|
||||||
|
BulkPolicyVersionRestoreJob::dispatch($run->id);
|
||||||
|
} else {
|
||||||
|
BulkPolicyVersionRestoreJob::dispatchSync($run->id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
|
||||||
|
BulkAction::make('bulk_force_delete_versions')
|
||||||
|
->label('Force Delete Versions')
|
||||||
|
->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()} policy versions?")
|
||||||
|
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions 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, array $data) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', $ids, $count);
|
||||||
|
|
||||||
|
if ($count >= 20) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk force delete started')
|
||||||
|
->body("Force deleting {$count} policy versions 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();
|
||||||
|
|
||||||
|
BulkPolicyVersionForceDeleteJob::dispatch($run->id);
|
||||||
|
} else {
|
||||||
|
BulkPolicyVersionForceDeleteJob::dispatchSync($run->id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$tenantId = Tenant::current()->getKey();
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
|
->with('policy');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
|
|||||||
@ -3,16 +3,22 @@
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource\Pages;
|
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\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GroupResolver;
|
use App\Services\Graph\GroupResolver;
|
||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Actions\BulkAction;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Infolists;
|
use Filament\Infolists;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -22,7 +28,10 @@
|
|||||||
use Filament\Schemas\Components\Utilities\Set;
|
use Filament\Schemas\Components\Utilities\Set;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -164,11 +173,40 @@ public static function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('requested_by')->label('Requested by'),
|
Tables\Columns\TextColumn::make('requested_by')->label('Requested by'),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\TrashedFilter::make(),
|
TrashedFilter::make()
|
||||||
|
->label('Archived')
|
||||||
|
->placeholder('Active')
|
||||||
|
->trueLabel('All')
|
||||||
|
->falseLabel('Archived'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
|
Actions\Action::make('restore')
|
||||||
|
->label('Restore')
|
||||||
|
->color('success')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (RestoreRun $record) => $record->trashed())
|
||||||
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
|
$record->restore();
|
||||||
|
|
||||||
|
if ($record->tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $record->tenant,
|
||||||
|
action: 'restore_run.restored',
|
||||||
|
resourceType: 'restore_run',
|
||||||
|
resourceId: (string) $record->id,
|
||||||
|
status: 'success',
|
||||||
|
context: ['metadata' => ['backup_set_id' => $record->backup_set_id]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore run restored')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
Actions\Action::make('archive')
|
Actions\Action::make('archive')
|
||||||
->label('Archive')
|
->label('Archive')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
@ -176,6 +214,16 @@ public static function table(Table $table): Table
|
|||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record) => ! $record->trashed())
|
->visible(fn (RestoreRun $record) => ! $record->trashed())
|
||||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
->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();
|
$record->delete();
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
@ -221,7 +269,158 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->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
|
public static function infolist(Schema $schema): Schema
|
||||||
|
|||||||
@ -97,10 +97,10 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\TrashedFilter::make()
|
Tables\Filters\TrashedFilter::make()
|
||||||
->label('Archive filter')
|
->label('Archived')
|
||||||
->placeholder('Active only')
|
->placeholder('Active')
|
||||||
->trueLabel('Active + archived')
|
->trueLabel('All')
|
||||||
->falseLabel('Archived only')
|
->falseLabel('Archived')
|
||||||
->default(true),
|
->default(true),
|
||||||
Tables\Filters\SelectFilter::make('app_status')
|
Tables\Filters\SelectFilter::make('app_status')
|
||||||
->options([
|
->options([
|
||||||
|
|||||||
150
app/Jobs/BulkBackupSetDeleteJob.php
Normal file
150
app/Jobs/BulkBackupSetDeleteJob.php
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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();
|
||||||
|
}
|
||||||
|
}
|
||||||
185
app/Jobs/BulkPolicyDeleteJob.php
Normal file
185
app/Jobs/BulkPolicyDeleteJob.php
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
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 BulkPolicyDeleteJob 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;
|
||||||
|
$failures = [];
|
||||||
|
|
||||||
|
$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 $policyId) {
|
||||||
|
|
||||||
|
$itemCount++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
$policy = Policy::find($policyId);
|
||||||
|
|
||||||
|
if (! $policy) {
|
||||||
|
|
||||||
|
$service->recordFailure($run, (string) $policyId, 'Policy not found');
|
||||||
|
$failed++;
|
||||||
|
$failures[] = [
|
||||||
|
'item_id' => (string) $policyId,
|
||||||
|
'reason' => 'Policy not found',
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($failed > $failureThreshold) {
|
||||||
|
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Delete Aborted')
|
||||||
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($policy->ignored_at) {
|
||||||
|
|
||||||
|
$service->recordSkipped($run);
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
$policy->ignore();
|
||||||
|
|
||||||
|
$service->recordSuccess($run);
|
||||||
|
$succeeded++;
|
||||||
|
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
|
||||||
|
$service->recordFailure($run, (string) $policyId, $e->getMessage());
|
||||||
|
$failed++;
|
||||||
|
$failures[] = [
|
||||||
|
'item_id' => (string) $policyId,
|
||||||
|
'reason' => $e->getMessage(),
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($failed > $failureThreshold) {
|
||||||
|
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Delete Aborted')
|
||||||
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the run from database every $chunkSize items to avoid stale data
|
||||||
|
|
||||||
|
if ($itemCount % $chunkSize === 0) {
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->complete($run);
|
||||||
|
|
||||||
|
if ($succeeded > 0 || $failed > 0 || $skipped > 0) {
|
||||||
|
$message = "Successfully deleted {$succeeded} policies";
|
||||||
|
if ($skipped > 0) {
|
||||||
|
$message .= " ({$skipped} skipped)";
|
||||||
|
}
|
||||||
|
if ($failed > 0) {
|
||||||
|
$message .= " ({$failed} failed)";
|
||||||
|
}
|
||||||
|
$message .= '.';
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Delete Completed')
|
||||||
|
->body($message)
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->success()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
|
||||||
|
$service->fail($run, $e->getMessage());
|
||||||
|
|
||||||
|
// Reload run with user relationship
|
||||||
|
$run->refresh();
|
||||||
|
$run->load('user');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Delete Failed')
|
||||||
|
->body($e->getMessage())
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
219
app/Jobs/BulkPolicyExportJob.php
Normal file
219
app/Jobs/BulkPolicyExportJob.php
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
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 BulkPolicyExportJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $bulkRunId,
|
||||||
|
public string $backupName,
|
||||||
|
public ?string $backupDescription = null
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(BulkOperationService $service): void
|
||||||
|
{
|
||||||
|
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||||
|
|
||||||
|
if (! $run || $run->status !== 'pending') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->start($run);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create Backup Set
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $run->tenant_id,
|
||||||
|
'name' => $this->backupName,
|
||||||
|
// 'description' => $this->backupDescription, // Not in schema
|
||||||
|
'status' => 'completed',
|
||||||
|
'created_by' => $run->user?->name ?? (string) $run->user_id, // Schema has created_by string
|
||||||
|
'item_count' => count($run->item_ids),
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$itemCount = 0;
|
||||||
|
$succeeded = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$failures = [];
|
||||||
|
$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 $policyId) {
|
||||||
|
$itemCount++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$policy = Policy::find($policyId);
|
||||||
|
|
||||||
|
if (! $policy) {
|
||||||
|
$service->recordFailure($run, (string) $policyId, 'Policy not found');
|
||||||
|
$failed++;
|
||||||
|
$failures[] = [
|
||||||
|
'item_id' => (string) $policyId,
|
||||||
|
'reason' => 'Policy not found',
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($failed > $failureThreshold) {
|
||||||
|
$backupSet->update(['status' => 'failed']);
|
||||||
|
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Export Aborted')
|
||||||
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get latest version for snapshot
|
||||||
|
$latestVersion = $policy->versions()->orderByDesc('captured_at')->first();
|
||||||
|
|
||||||
|
if (! $latestVersion) {
|
||||||
|
$service->recordFailure($run, (string) $policyId, 'No versions available for policy');
|
||||||
|
$failed++;
|
||||||
|
$failures[] = [
|
||||||
|
'item_id' => (string) $policyId,
|
||||||
|
'reason' => 'No versions available for policy',
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($failed > $failureThreshold) {
|
||||||
|
$backupSet->update(['status' => 'failed']);
|
||||||
|
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Export Aborted')
|
||||||
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Backup Item
|
||||||
|
BackupItem::create([
|
||||||
|
'tenant_id' => $run->tenant_id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_identifier' => $policy->external_id, // Added
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform ?? null, // Added
|
||||||
|
// 'display_name' => $policy->display_name, // Not in schema, maybe in metadata?
|
||||||
|
'payload' => $latestVersion->snapshot, // Mapped to payload
|
||||||
|
'metadata' => [
|
||||||
|
'display_name' => $policy->display_name, // Stored in metadata
|
||||||
|
'version_captured_at' => $latestVersion->captured_at->toIso8601String(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service->recordSuccess($run);
|
||||||
|
$succeeded++;
|
||||||
|
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$service->recordFailure($run, (string) $policyId, $e->getMessage());
|
||||||
|
$failed++;
|
||||||
|
$failures[] = [
|
||||||
|
'item_id' => (string) $policyId,
|
||||||
|
'reason' => $e->getMessage(),
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($failed > $failureThreshold) {
|
||||||
|
$backupSet->update(['status' => 'failed']);
|
||||||
|
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Export Aborted')
|
||||||
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the run from database every 10 items to avoid stale data
|
||||||
|
if ($itemCount % $chunkSize === 0) {
|
||||||
|
$run->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update BackupSet item count (if denormalized) or just leave it
|
||||||
|
// Assuming BackupSet might need an item count or status update
|
||||||
|
|
||||||
|
$service->complete($run);
|
||||||
|
|
||||||
|
if ($succeeded > 0 || $failed > 0) {
|
||||||
|
$message = "Successfully exported {$succeeded} policies to backup '{$this->backupName}'";
|
||||||
|
if ($failed > 0) {
|
||||||
|
$message .= " ({$failed} failed)";
|
||||||
|
}
|
||||||
|
$message .= '.';
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Export Completed')
|
||||||
|
->body($message)
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->success()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$service->fail($run, $e->getMessage());
|
||||||
|
|
||||||
|
// Reload run with user relationship
|
||||||
|
$run->refresh();
|
||||||
|
$run->load('user');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Export Failed')
|
||||||
|
->body($e->getMessage())
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
147
app/Jobs/BulkPolicySyncJob.php
Normal file
147
app/Jobs/BulkPolicySyncJob.php
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
|
use App\Services\Intune\PolicySyncService;
|
||||||
|
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 BulkPolicySyncJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public int $bulkRunId) {}
|
||||||
|
|
||||||
|
public function handle(BulkOperationService $service, PolicySyncService $syncService): void
|
||||||
|
{
|
||||||
|
$run = BulkOperationRun::with(['tenant', 'user'])->find($this->bulkRunId);
|
||||||
|
|
||||||
|
if (! $run || $run->status !== 'pending') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->start($run);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||||
|
$itemCount = 0;
|
||||||
|
|
||||||
|
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||||
|
$failureThreshold = (int) floor($totalItems / 2);
|
||||||
|
|
||||||
|
foreach (($run->item_ids ?? []) as $policyId) {
|
||||||
|
$itemCount++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$policy = Policy::query()
|
||||||
|
->whereKey($policyId)
|
||||||
|
->where('tenant_id', $run->tenant_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $policy) {
|
||||||
|
$service->recordFailure($run, (string) $policyId, 'Policy not found');
|
||||||
|
|
||||||
|
if ($run->failed > $failureThreshold) {
|
||||||
|
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Sync Aborted')
|
||||||
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($policy->ignored_at) {
|
||||||
|
$service->recordSkippedWithReason($run, (string) $policyId, 'Policy is ignored locally');
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$syncService->syncPolicy($run->tenant, $policy);
|
||||||
|
|
||||||
|
$service->recordSuccess($run);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$service->recordFailure($run, (string) $policyId, $e->getMessage());
|
||||||
|
|
||||||
|
if ($run->failed > $failureThreshold) {
|
||||||
|
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Sync Aborted')
|
||||||
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($itemCount % $chunkSize === 0) {
|
||||||
|
$run->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->complete($run);
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
$message = "Synced {$run->succeeded} policies";
|
||||||
|
|
||||||
|
if ($run->skipped > 0) {
|
||||||
|
$message .= " ({$run->skipped} skipped)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($run->failed > 0) {
|
||||||
|
$message .= " ({$run->failed} failed)";
|
||||||
|
}
|
||||||
|
|
||||||
|
$message .= '.';
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Sync Completed')
|
||||||
|
->body($message)
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->success()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$service->fail($run, $e->getMessage());
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
$run->load('user');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Sync Failed')
|
||||||
|
->body($e->getMessage())
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
app/Jobs/BulkPolicyUnignoreJob.php
Normal file
116
app/Jobs/BulkPolicyUnignoreJob.php
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
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 BulkPolicyUnignoreJob 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;
|
||||||
|
|
||||||
|
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||||
|
|
||||||
|
foreach ($run->item_ids as $policyId) {
|
||||||
|
$itemCount++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$policy = Policy::find($policyId);
|
||||||
|
|
||||||
|
if (! $policy) {
|
||||||
|
$service->recordFailure($run, (string) $policyId, 'Policy not found');
|
||||||
|
$failed++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $policy->ignored_at) {
|
||||||
|
$service->recordSkipped($run);
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$policy->unignore();
|
||||||
|
|
||||||
|
$service->recordSuccess($run);
|
||||||
|
$succeeded++;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$service->recordFailure($run, (string) $policyId, $e->getMessage());
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($itemCount % $chunkSize === 0) {
|
||||||
|
$run->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->complete($run);
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
$message = "Restored {$succeeded} policies";
|
||||||
|
|
||||||
|
if ($skipped > 0) {
|
||||||
|
$message .= " ({$skipped} skipped)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failed > 0) {
|
||||||
|
$message .= " ({$failed} failed)";
|
||||||
|
}
|
||||||
|
|
||||||
|
$message .= '.';
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Restore 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 Restore Failed')
|
||||||
|
->body($e->getMessage())
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
app/Jobs/BulkPolicyVersionForceDeleteJob.php
Normal file
148
app/Jobs/BulkPolicyVersionForceDeleteJob.php
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
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 BulkPolicyVersionForceDeleteJob 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 $versionId) {
|
||||||
|
$itemCount++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var PolicyVersion|null $version */
|
||||||
|
$version = PolicyVersion::withTrashed()
|
||||||
|
->where('tenant_id', $run->tenant_id)
|
||||||
|
->whereKey($versionId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $version) {
|
||||||
|
$service->recordFailure($run, (string) $versionId, 'Policy version not found');
|
||||||
|
$failed++;
|
||||||
|
|
||||||
|
if ($failed > $failureThreshold) {
|
||||||
|
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Force Delete Aborted')
|
||||||
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $version->trashed()) {
|
||||||
|
$service->recordSkippedWithReason($run, (string) $version->id, 'Not archived');
|
||||||
|
$skipped++;
|
||||||
|
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$version->forceDelete();
|
||||||
|
$service->recordSuccess($run);
|
||||||
|
$succeeded++;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$service->recordFailure($run, (string) $versionId, $e->getMessage());
|
||||||
|
$failed++;
|
||||||
|
|
||||||
|
if ($failed > $failureThreshold) {
|
||||||
|
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Force Delete Aborted')
|
||||||
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($itemCount % $chunkSize === 0) {
|
||||||
|
$run->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->complete($run);
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
$message = "Force deleted {$succeeded} policy versions";
|
||||||
|
if ($skipped > 0) {
|
||||||
|
$message .= " ({$skipped} skipped)";
|
||||||
|
}
|
||||||
|
if ($failed > 0) {
|
||||||
|
$message .= " ({$failed} failed)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($skipReasons)) {
|
||||||
|
$summary = collect($skipReasons)
|
||||||
|
->sortDesc()
|
||||||
|
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||||
|
->take(3)
|
||||||
|
->implode(', ');
|
||||||
|
|
||||||
|
if ($summary !== '') {
|
||||||
|
$message .= " Skip reasons: {$summary}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$message .= '.';
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Force Delete Completed')
|
||||||
|
->body($message)
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->success()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
178
app/Jobs/BulkPolicyVersionPruneJob.php
Normal file
178
app/Jobs/BulkPolicyVersionPruneJob.php
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
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 BulkPolicyVersionPruneJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $bulkRunId,
|
||||||
|
public int $retentionDays = 90,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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 $versionId) {
|
||||||
|
$itemCount++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var PolicyVersion|null $version */
|
||||||
|
$version = PolicyVersion::withTrashed()
|
||||||
|
->where('tenant_id', $run->tenant_id)
|
||||||
|
->whereKey($versionId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $version) {
|
||||||
|
$service->recordFailure($run, (string) $versionId, 'Policy version not found');
|
||||||
|
$failed++;
|
||||||
|
|
||||||
|
if ($failed > $failureThreshold) {
|
||||||
|
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Prune Aborted')
|
||||||
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($version->trashed()) {
|
||||||
|
$service->recordSkippedWithReason($run, (string) $version->id, 'Already archived');
|
||||||
|
$skipped++;
|
||||||
|
$skipReasons['Already archived'] = ($skipReasons['Already archived'] ?? 0) + 1;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$eligible = PolicyVersion::query()
|
||||||
|
->where('tenant_id', $run->tenant_id)
|
||||||
|
->whereKey($version->id)
|
||||||
|
->pruneEligible($this->retentionDays)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $eligible) {
|
||||||
|
$capturedAt = $version->captured_at;
|
||||||
|
$isTooRecent = $capturedAt && $capturedAt->gte(now()->subDays($this->retentionDays));
|
||||||
|
|
||||||
|
$latestVersionNumber = PolicyVersion::query()
|
||||||
|
->where('tenant_id', $run->tenant_id)
|
||||||
|
->where('policy_id', $version->policy_id)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->max('version_number');
|
||||||
|
|
||||||
|
$isCurrent = $latestVersionNumber !== null && (int) $version->version_number === (int) $latestVersionNumber;
|
||||||
|
|
||||||
|
$reason = $isCurrent
|
||||||
|
? 'Current version'
|
||||||
|
: ($isTooRecent ? 'Too recent' : 'Not eligible');
|
||||||
|
|
||||||
|
$service->recordSkippedWithReason($run, (string) $version->id, $reason);
|
||||||
|
$skipped++;
|
||||||
|
$skipReasons[$reason] = ($skipReasons[$reason] ?? 0) + 1;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$version->delete();
|
||||||
|
$service->recordSuccess($run);
|
||||||
|
$succeeded++;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$service->recordFailure($run, (string) $versionId, $e->getMessage());
|
||||||
|
$failed++;
|
||||||
|
|
||||||
|
if ($failed > $failureThreshold) {
|
||||||
|
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Prune Aborted')
|
||||||
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($itemCount % $chunkSize === 0) {
|
||||||
|
$run->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->complete($run);
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
$message = "Pruned {$succeeded} policy versions";
|
||||||
|
if ($skipped > 0) {
|
||||||
|
$message .= " ({$skipped} skipped)";
|
||||||
|
}
|
||||||
|
if ($failed > 0) {
|
||||||
|
$message .= " ({$failed} failed)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($skipReasons)) {
|
||||||
|
$summary = collect($skipReasons)
|
||||||
|
->sortDesc()
|
||||||
|
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||||
|
->take(3)
|
||||||
|
->implode(', ');
|
||||||
|
|
||||||
|
if ($summary !== '') {
|
||||||
|
$message .= " Skip reasons: {$summary}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$message .= '.';
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Prune Completed')
|
||||||
|
->body($message)
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->success()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
app/Jobs/BulkPolicyVersionRestoreJob.php
Normal file
148
app/Jobs/BulkPolicyVersionRestoreJob.php
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
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 BulkPolicyVersionRestoreJob 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 $versionId) {
|
||||||
|
$itemCount++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var PolicyVersion|null $version */
|
||||||
|
$version = PolicyVersion::withTrashed()
|
||||||
|
->where('tenant_id', $run->tenant_id)
|
||||||
|
->whereKey($versionId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $version) {
|
||||||
|
$service->recordFailure($run, (string) $versionId, 'Policy version not found');
|
||||||
|
$failed++;
|
||||||
|
|
||||||
|
if ($failed > $failureThreshold) {
|
||||||
|
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Restore Aborted')
|
||||||
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $version->trashed()) {
|
||||||
|
$service->recordSkippedWithReason($run, (string) $version->id, 'Not archived');
|
||||||
|
$skipped++;
|
||||||
|
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$version->restore();
|
||||||
|
$service->recordSuccess($run);
|
||||||
|
$succeeded++;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$service->recordFailure($run, (string) $versionId, $e->getMessage());
|
||||||
|
$failed++;
|
||||||
|
|
||||||
|
if ($failed > $failureThreshold) {
|
||||||
|
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Restore Aborted')
|
||||||
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($itemCount % $chunkSize === 0) {
|
||||||
|
$run->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->complete($run);
|
||||||
|
|
||||||
|
if ($run->user) {
|
||||||
|
$message = "Restored {$succeeded} policy versions";
|
||||||
|
if ($skipped > 0) {
|
||||||
|
$message .= " ({$skipped} skipped)";
|
||||||
|
}
|
||||||
|
if ($failed > 0) {
|
||||||
|
$message .= " ({$failed} failed)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($skipReasons)) {
|
||||||
|
$summary = collect($skipReasons)
|
||||||
|
->sortDesc()
|
||||||
|
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||||
|
->take(3)
|
||||||
|
->implode(', ');
|
||||||
|
|
||||||
|
if ($summary !== '') {
|
||||||
|
$message .= " Skip reasons: {$summary}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$message .= '.';
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk Restore Completed')
|
||||||
|
->body($message)
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->success()
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Livewire/BulkOperationProgress.php
Normal file
50
app/Livewire/BulkOperationProgress.php
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function activeRuns()
|
||||||
|
{
|
||||||
|
return $this->runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadRuns()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
$this->runs = collect();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->runs = BulkOperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('user_id', auth()->id())
|
||||||
|
->whereIn('status', ['pending', 'running'])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): \Illuminate\Contracts\View\View
|
||||||
|
{
|
||||||
|
return view('livewire.bulk-operation-progress');
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Models/BulkOperationRun.php
Normal file
53
app/Models/BulkOperationRun.php
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class BulkOperationRun extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'user_id',
|
||||||
|
'resource',
|
||||||
|
'action',
|
||||||
|
'status',
|
||||||
|
'total_items',
|
||||||
|
'processed_items',
|
||||||
|
'succeeded',
|
||||||
|
'failed',
|
||||||
|
'skipped',
|
||||||
|
'item_ids',
|
||||||
|
'failures',
|
||||||
|
'audit_log_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'item_ids' => 'array',
|
||||||
|
'failures' => 'array',
|
||||||
|
'processed_items' => 'integer',
|
||||||
|
'total_items' => 'integer',
|
||||||
|
'succeeded' => 'integer',
|
||||||
|
'failed' => 'integer',
|
||||||
|
'skipped' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function auditLog(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AuditLog::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@ class Policy extends Model
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'metadata' => 'array',
|
'metadata' => 'array',
|
||||||
'last_synced_at' => 'datetime',
|
'last_synced_at' => 'datetime',
|
||||||
|
'ignored_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
public function tenant(): BelongsTo
|
||||||
@ -34,4 +35,24 @@ public function backupItems(): HasMany
|
|||||||
{
|
{
|
||||||
return $this->hasMany(BackupItem::class);
|
return $this->hasMany(BackupItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->whereNull('ignored_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeIgnored($query)
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('ignored_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ignore(): void
|
||||||
|
{
|
||||||
|
$this->update(['ignored_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unignore(): void
|
||||||
|
{
|
||||||
|
$this->update(['ignored_at' => null]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,4 +42,14 @@ public function policy(): BelongsTo
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Policy::class);
|
return $this->belongsTo(Policy::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopePruneEligible($query, int $days = 90)
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->where('captured_at', '<', now()->subDays($days))
|
||||||
|
->whereRaw(
|
||||||
|
'policy_versions.version_number < (select max(pv2.version_number) from policy_versions pv2 where pv2.policy_id = policy_versions.policy_id and pv2.deleted_at is null)'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,7 +32,20 @@ public function tenant(): BelongsTo
|
|||||||
|
|
||||||
public function backupSet(): BelongsTo
|
public function backupSet(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(BackupSet::class);
|
return $this->belongsTo(BackupSet::class)->withTrashed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeDeletable($query)
|
||||||
|
{
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group mapping helpers
|
// Group mapping helpers
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Filament\PanelProvider;
|
use Filament\PanelProvider;
|
||||||
use Filament\Support\Colors\Color;
|
use Filament\Support\Colors\Color;
|
||||||
|
use Filament\View\PanelsRenderHook;
|
||||||
use Filament\Widgets\AccountWidget;
|
use Filament\Widgets\AccountWidget;
|
||||||
use Filament\Widgets\FilamentInfoWidget;
|
use Filament\Widgets\FilamentInfoWidget;
|
||||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
@ -31,6 +32,10 @@ public function panel(Panel $panel): Panel
|
|||||||
->colors([
|
->colors([
|
||||||
'primary' => Color::Amber,
|
'primary' => Color::Amber,
|
||||||
])
|
])
|
||||||
|
->renderHook(
|
||||||
|
PanelsRenderHook::BODY_END,
|
||||||
|
fn () => view('livewire.bulk-operation-progress-wrapper')->render()
|
||||||
|
)
|
||||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||||
->pages([
|
->pages([
|
||||||
@ -41,6 +46,7 @@ public function panel(Panel $panel): Panel
|
|||||||
AccountWidget::class,
|
AccountWidget::class,
|
||||||
FilamentInfoWidget::class,
|
FilamentInfoWidget::class,
|
||||||
])
|
])
|
||||||
|
->databaseNotifications()
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
AddQueuedCookiesToResponse::class,
|
AddQueuedCookiesToResponse::class,
|
||||||
|
|||||||
189
app/Services/BulkOperationService.php
Normal file
189
app/Services/BulkOperationService.php
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
|
||||||
|
class BulkOperationService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected AuditLogger $auditLogger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function createRun(
|
||||||
|
Tenant $tenant,
|
||||||
|
User $user,
|
||||||
|
string $resource,
|
||||||
|
string $action,
|
||||||
|
array $itemIds,
|
||||||
|
int $totalItems
|
||||||
|
): BulkOperationRun {
|
||||||
|
$run = BulkOperationRun::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'resource' => $resource,
|
||||||
|
'action' => $action,
|
||||||
|
'status' => 'pending',
|
||||||
|
'item_ids' => $itemIds,
|
||||||
|
'total_items' => $totalItems,
|
||||||
|
'processed_items' => 0,
|
||||||
|
'succeeded' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'failures' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$auditLog = $this->auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: "bulk.{$resource}.{$action}.created",
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'bulk_run_id' => $run->id,
|
||||||
|
'total_items' => $totalItems,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $user->id,
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
resourceType: 'bulk_operation_run',
|
||||||
|
resourceId: (string) $run->id
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->update(['audit_log_id' => $auditLog->id]);
|
||||||
|
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(BulkOperationRun $run): void
|
||||||
|
{
|
||||||
|
$run->update(['status' => 'running']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordSuccess(BulkOperationRun $run): void
|
||||||
|
{
|
||||||
|
$run->increment('processed_items');
|
||||||
|
$run->increment('succeeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordFailure(BulkOperationRun $run, string $itemId, string $reason): void
|
||||||
|
{
|
||||||
|
$failures = $run->failures ?? [];
|
||||||
|
$failures[] = [
|
||||||
|
'item_id' => $itemId,
|
||||||
|
'reason' => $reason,
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$run->update([
|
||||||
|
'failures' => $failures,
|
||||||
|
'processed_items' => $run->processed_items + 1,
|
||||||
|
'failed' => $run->failed + 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordSkipped(BulkOperationRun $run): void
|
||||||
|
{
|
||||||
|
$run->increment('processed_items');
|
||||||
|
$run->increment('skipped');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, string $reason): void
|
||||||
|
{
|
||||||
|
$failures = $run->failures ?? [];
|
||||||
|
$failures[] = [
|
||||||
|
'item_id' => $itemId,
|
||||||
|
'reason' => $reason,
|
||||||
|
'type' => 'skipped',
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$run->update([
|
||||||
|
'failures' => $failures,
|
||||||
|
'processed_items' => $run->processed_items + 1,
|
||||||
|
'skipped' => $run->skipped + 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(BulkOperationRun $run): void
|
||||||
|
{
|
||||||
|
$status = $run->failed > 0 ? 'completed_with_errors' : 'completed';
|
||||||
|
$run->update(['status' => $status]);
|
||||||
|
|
||||||
|
$failureEntries = collect($run->failures ?? []);
|
||||||
|
$failedReasons = $failureEntries
|
||||||
|
->filter(fn (array $entry) => ($entry['type'] ?? 'failed') !== 'skipped')
|
||||||
|
->groupBy('reason')
|
||||||
|
->map(fn ($group) => $group->count())
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$skippedReasons = $failureEntries
|
||||||
|
->filter(fn (array $entry) => ($entry['type'] ?? null) === 'skipped')
|
||||||
|
->groupBy('reason')
|
||||||
|
->map(fn ($group) => $group->count())
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
tenant: $run->tenant,
|
||||||
|
action: "bulk.{$run->resource}.{$run->action}.{$status}",
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'bulk_run_id' => $run->id,
|
||||||
|
'succeeded' => $run->succeeded,
|
||||||
|
'failed' => $run->failed,
|
||||||
|
'skipped' => $run->skipped,
|
||||||
|
'failed_reasons' => $failedReasons,
|
||||||
|
'skipped_reasons' => $skippedReasons,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $run->user_id,
|
||||||
|
resourceType: 'bulk_operation_run',
|
||||||
|
resourceId: (string) $run->id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fail(BulkOperationRun $run, string $reason): void
|
||||||
|
{
|
||||||
|
$run->update(['status' => 'failed']);
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
tenant: $run->tenant,
|
||||||
|
action: "bulk.{$run->resource}.{$run->action}.failed",
|
||||||
|
context: [
|
||||||
|
'reason' => $reason,
|
||||||
|
'metadata' => [
|
||||||
|
'bulk_run_id' => $run->id,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $run->user_id,
|
||||||
|
status: 'failure',
|
||||||
|
resourceType: 'bulk_operation_run',
|
||||||
|
resourceId: (string) $run->id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function abort(BulkOperationRun $run, string $reason): void
|
||||||
|
{
|
||||||
|
$run->update(['status' => 'aborted']);
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
tenant: $run->tenant,
|
||||||
|
action: "bulk.{$run->resource}.{$run->action}.aborted",
|
||||||
|
context: [
|
||||||
|
'reason' => $reason,
|
||||||
|
'metadata' => [
|
||||||
|
'bulk_run_id' => $run->id,
|
||||||
|
'succeeded' => $run->succeeded,
|
||||||
|
'failed' => $run->failed,
|
||||||
|
'skipped' => $run->skipped,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $run->user_id,
|
||||||
|
status: 'failure',
|
||||||
|
resourceType: 'bulk_operation_run',
|
||||||
|
resourceId: (string) $run->id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Services\Graph\GraphErrorMapper;
|
use App\Services\Graph\GraphErrorMapper;
|
||||||
use App\Services\Graph\GraphLogger;
|
use App\Services\Graph\GraphLogger;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use RuntimeException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class PolicySyncService
|
class PolicySyncService
|
||||||
@ -97,6 +98,7 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
|
|||||||
'display_name' => $displayName,
|
'display_name' => $displayName,
|
||||||
'platform' => $policyPlatform,
|
'platform' => $policyPlatform,
|
||||||
'last_synced_at' => now(),
|
'last_synced_at' => now(),
|
||||||
|
'ignored_at' => null,
|
||||||
'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']),
|
'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@ -107,4 +109,68 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
|
|||||||
|
|
||||||
return $synced;
|
return $synced;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-fetch a single policy from Graph and update local metadata.
|
||||||
|
*/
|
||||||
|
public function syncPolicy(Tenant $tenant, Policy $policy): void
|
||||||
|
{
|
||||||
|
if (! $tenant->isActive()) {
|
||||||
|
throw new RuntimeException('Tenant is archived or inactive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||||
|
|
||||||
|
$this->graphLogger->logRequest('get_policy', [
|
||||||
|
'tenant' => $tenantIdentifier,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, [
|
||||||
|
'tenant' => $tenantIdentifier,
|
||||||
|
'client_id' => $tenant->app_client_id,
|
||||||
|
'client_secret' => $tenant->app_client_secret,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
throw GraphErrorMapper::fromThrowable($throwable, [
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'tenant_identifier' => $tenantIdentifier,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->graphLogger->logResponse('get_policy', $response, [
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'tenant' => $tenantIdentifier,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
$message = $response->errors[0]['message'] ?? $response->data['error']['message'] ?? 'Graph request failed.';
|
||||||
|
|
||||||
|
throw new RuntimeException($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $response->data['payload'] ?? $response->data;
|
||||||
|
|
||||||
|
if (! is_array($payload)) {
|
||||||
|
throw new RuntimeException('Invalid Graph response payload.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$displayName = $payload['displayName'] ?? $payload['name'] ?? $policy->display_name;
|
||||||
|
$platform = $payload['platform'] ?? $policy->platform;
|
||||||
|
|
||||||
|
$policy->forceFill([
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'platform' => $platform,
|
||||||
|
'last_synced_at' => now(),
|
||||||
|
'metadata' => Arr::except($payload, ['id', 'external_id', 'displayName', 'name', 'platform']),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -118,4 +118,9 @@
|
|||||||
'features' => [
|
'features' => [
|
||||||
'conditional_access' => true,
|
'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),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
34
database/factories/BulkOperationRunFactory.php
Normal file
34
database/factories/BulkOperationRunFactory.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BulkOperationRun>
|
||||||
|
*/
|
||||||
|
class BulkOperationRunFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenant_id' => \App\Models\Tenant::factory(),
|
||||||
|
'user_id' => \App\Models\User::factory(),
|
||||||
|
'resource' => 'policy',
|
||||||
|
'action' => 'delete',
|
||||||
|
'status' => 'pending',
|
||||||
|
'total_items' => 10,
|
||||||
|
'processed_items' => 0,
|
||||||
|
'succeeded' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'item_ids' => range(1, 10),
|
||||||
|
'failures' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,9 +20,9 @@ public function definition(): array
|
|||||||
return [
|
return [
|
||||||
'tenant_id' => Tenant::factory(),
|
'tenant_id' => Tenant::factory(),
|
||||||
'external_id' => fake()->uuid(),
|
'external_id' => fake()->uuid(),
|
||||||
|
'display_name' => fake()->words(3, true),
|
||||||
'policy_type' => 'settingsCatalogPolicy',
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10']),
|
'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10']),
|
||||||
'display_name' => fake()->words(3, true),
|
|
||||||
'last_synced_at' => now(),
|
'last_synced_at' => now(),
|
||||||
'metadata' => [],
|
'metadata' => [],
|
||||||
];
|
];
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -17,14 +19,15 @@ class PolicyVersionFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tenant_id' => \App\Models\Tenant::factory(),
|
'tenant_id' => Tenant::factory(),
|
||||||
'policy_id' => \App\Models\Policy::factory(),
|
'policy_id' => Policy::factory(),
|
||||||
'version_number' => 1,
|
'version_number' => 1,
|
||||||
'policy_type' => 'deviceManagementConfigurationPolicy',
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
'platform' => 'windows10',
|
'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10']),
|
||||||
'snapshot' => ['test' => 'data'],
|
'created_by' => fake()->safeEmail(),
|
||||||
'metadata' => [],
|
|
||||||
'captured_at' => now(),
|
'captured_at' => now(),
|
||||||
|
'snapshot' => ['example' => true],
|
||||||
|
'metadata' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,9 +18,15 @@ public function definition(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => fake()->company(),
|
'name' => fake()->company(),
|
||||||
|
'external_id' => fake()->uuid(),
|
||||||
'tenant_id' => fake()->uuid(),
|
'tenant_id' => fake()->uuid(),
|
||||||
'app_client_id' => fake()->uuid(),
|
'app_client_id' => fake()->uuid(),
|
||||||
'app_client_secret' => null, // Skip encryption in tests
|
'app_client_secret' => null, // Skip encryption in tests
|
||||||
|
'app_certificate_thumbprint' => null,
|
||||||
|
'app_status' => 'ok',
|
||||||
|
'app_notes' => null,
|
||||||
|
'status' => 'active',
|
||||||
|
'is_current' => false,
|
||||||
'metadata' => [],
|
'metadata' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('bulk_operation_runs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('resource', 50);
|
||||||
|
$table->string('action', 50);
|
||||||
|
$table->string('status', 20)->default('pending');
|
||||||
|
$table->unsignedInteger('total_items');
|
||||||
|
$table->unsignedInteger('processed_items')->default(0);
|
||||||
|
$table->unsignedInteger('succeeded')->default(0);
|
||||||
|
$table->unsignedInteger('failed')->default(0);
|
||||||
|
$table->unsignedInteger('skipped')->default(0);
|
||||||
|
$table->jsonb('item_ids');
|
||||||
|
$table->jsonb('failures')->nullable();
|
||||||
|
$table->foreignId('audit_log_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('bulk_operation_runs', function (Blueprint $table) {
|
||||||
|
$table->index(['tenant_id', 'resource', 'status'], 'bulk_runs_tenant_resource_status');
|
||||||
|
$table->index(['user_id', 'created_at'], 'bulk_runs_user_created');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::statement("CREATE INDEX bulk_runs_status_active ON bulk_operation_runs (status) WHERE status IN ('pending', 'running')");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::statement('DROP INDEX IF EXISTS bulk_runs_status_active');
|
||||||
|
|
||||||
|
Schema::dropIfExists('bulk_operation_runs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('policies', function (Blueprint $table) {
|
||||||
|
$table->timestamp('ignored_at')->nullable()->after('updated_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('policies', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('ignored_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('notifications', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->string('type');
|
||||||
|
$table->morphs('notifiable');
|
||||||
|
$table->jsonb('data');
|
||||||
|
$table->timestamp('read_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('notifications');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('bulk_operation_runs', function (Blueprint $table) {
|
||||||
|
$table->string('status', 50)->default('pending')->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('bulk_operation_runs', function (Blueprint $table) {
|
||||||
|
$table->string('status', 20)->default('pending')->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
33
database/seeders/BulkOperationsTestSeeder.php
Normal file
33
database/seeders/BulkOperationsTestSeeder.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class BulkOperationsTestSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$tenant = \App\Models\Tenant::first() ?? \App\Models\Tenant::factory()->create();
|
||||||
|
$user = \App\Models\User::first() ?? \App\Models\User::factory()->create();
|
||||||
|
|
||||||
|
// Create some policies to test bulk delete
|
||||||
|
\App\Models\Policy::factory()->count(30)->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create a completed bulk run
|
||||||
|
\App\Models\BulkOperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'status' => 'completed',
|
||||||
|
'total_items' => 10,
|
||||||
|
'processed_items' => 10,
|
||||||
|
'succeeded' => 10,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,6 +24,30 @@ services:
|
|||||||
- pgsql
|
- pgsql
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
|
queue:
|
||||||
|
build:
|
||||||
|
context: ./vendor/laravel/sail/runtimes/8.4
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
WWWGROUP: '${WWWGROUP:-1000}'
|
||||||
|
NODE_VERSION: '20'
|
||||||
|
image: tenantatlas-laravel
|
||||||
|
extra_hosts:
|
||||||
|
- 'host.docker.internal:host-gateway'
|
||||||
|
environment:
|
||||||
|
WWWUSER: '${WWWUSER:-1000}'
|
||||||
|
LARAVEL_SAIL: 1
|
||||||
|
APP_SERVICE: queue
|
||||||
|
volumes:
|
||||||
|
- '.:/var/www/html'
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
depends_on:
|
||||||
|
- laravel.test
|
||||||
|
- pgsql
|
||||||
|
- redis
|
||||||
|
command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000
|
||||||
|
|
||||||
pgsql:
|
pgsql:
|
||||||
image: 'postgres:16'
|
image: 'postgres:16'
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
<livewire:bulk-operation-progress />
|
||||||
75
resources/views/livewire/bulk-operation-progress.blade.php
Normal file
75
resources/views/livewire/bulk-operation-progress.blade.php
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<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;">
|
||||||
|
@foreach ($runs as $run)
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl border-2 border-primary-500 dark:border-primary-400 p-4 transition-all animate-in slide-in-from-right duration-300"
|
||||||
|
wire:key="run-{{ $run->id }}">
|
||||||
|
|
||||||
|
<div class="flex justify-between items-start mb-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ ucfirst($run->action) }} {{ ucfirst(str_replace('_', ' ', $run->resource)) }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
@if($run->status === 'pending')
|
||||||
|
<span class="inline-flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-1.5 h-3 w-3 text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Starting...
|
||||||
|
</span>
|
||||||
|
@elseif($run->status === 'running')
|
||||||
|
<span class="inline-flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-1.5 h-3 w-3 text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $run->processed_items }} / {{ $run->total_items }}
|
||||||
|
</span>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{{ $run->total_items > 0 ? round(($run->processed_items / $run->total_items) * 100) : 0 }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-3 dark:bg-gray-700 overflow-hidden">
|
||||||
|
<div class="bg-primary-600 dark:bg-primary-500 h-3 rounded-full transition-all duration-300 ease-out"
|
||||||
|
style="width: {{ $run->total_items > 0 ? ($run->processed_items / $run->total_items) * 100 : 0 }}%"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex items-center justify-between text-xs">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
@if ($run->succeeded > 0)
|
||||||
|
<span class="text-success-600 dark:text-success-400">
|
||||||
|
✓ {{ $run->succeeded }} succeeded
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
@if ($run->failed > 0)
|
||||||
|
<span class="text-danger-600 dark:text-danger-400">
|
||||||
|
✗ {{ $run->failed }} failed
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
@if ($run->skipped > 0)
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
⊘ {{ $run->skipped }} skipped
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">
|
||||||
|
{{ $run->created_at->diffForHumans(null, true, true) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
12
specs/005-bulk-operations/contracts/openapi.yaml
Normal file
12
specs/005-bulk-operations/contracts/openapi.yaml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: TenantPilot - Bulk Operations (Feature 005)
|
||||||
|
version: 0.0.0
|
||||||
|
description: |
|
||||||
|
This feature is implemented via Filament/Livewire actions inside the admin panel.
|
||||||
|
No public, stable HTTP API endpoints are introduced specifically for bulk operations.
|
||||||
|
|
||||||
|
This OpenAPI document is intentionally minimal.
|
||||||
|
servers: []
|
||||||
|
paths: {}
|
||||||
|
components: {}
|
||||||
@ -1,82 +1,38 @@
|
|||||||
# Implementation Plan: Feature 005 - Bulk Operations
|
# Implementation Plan: Feature 005 - Bulk Operations
|
||||||
|
|
||||||
**Branch**: `feat/005-bulk-operations` | **Date**: 2025-12-22 | **Spec**: [spec.md](./spec.md)
|
**Branch**: `feat/005-bulk-operations` | **Date**: 2025-12-25 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/005-bulk-operations/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Enable efficient bulk operations (delete, export, prune) across TenantPilot's main resources (Policies, Policy Versions, Backup Sets, Restore Runs) with safety gates, progress tracking, and comprehensive audit logging. Technical approach: Filament bulk actions + Laravel Queue jobs with chunked processing + BulkOperationRun tracking model + Livewire polling for progress updates.
|
Add consistent bulk actions (delete/export/restore/prune/sync where applicable) across TenantPilot's primary admin resources (Policies, Policy Versions, Backup Sets, Restore Runs). Bulk operations create a tracking record, enforce permissions, support type-to-confirm for large destructive changes, and run asynchronously via queue for larger selections with progress tracking.
|
||||||
|
|
||||||
## Technical Context
|
## Technical Context
|
||||||
|
|
||||||
|
|
||||||
**Language/Version**: PHP 8.4.15
|
**Language/Version**: PHP 8.4.15
|
||||||
**Framework**: Laravel 12
|
**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3
|
||||||
**Primary Dependencies**:
|
**Storage**: PostgreSQL (app), SQLite in-memory (tests)
|
||||||
- Filament v4 (admin panel + bulk actions)
|
**Testing**: Pest v4 + PHPUnit 12
|
||||||
- Livewire v3 (reactive UI + polling)
|
**Target Platform**: Containerized Linux (Sail/Dokploy)
|
||||||
- Laravel Queue (async job processing)
|
**Project Type**: Web application (Laravel + Filament admin panel)
|
||||||
- PostgreSQL (JSONB for tracking)
|
**Performance Goals**: Handle bulk actions up to hundreds of items with predictable runtime; keep UI responsive via queued processing for larger selections
|
||||||
|
**Constraints**: Tenant isolation; least privilege; safe destructive actions (confirmation + auditability); avoid long locks/timeouts by chunking
|
||||||
**Storage**: PostgreSQL with JSONB fields for:
|
**Scale/Scope**: Admin-focused operations, moderate concurrency, emphasis on correctness/auditability over throughput
|
||||||
- `bulk_operation_runs.item_ids` (array of resource IDs)
|
|
||||||
- `bulk_operation_runs.failures` (per-item error details)
|
|
||||||
- Existing audit logs (metadata column)
|
|
||||||
|
|
||||||
**Testing**: Pest v4 (unit, feature, browser tests)
|
|
||||||
**Target Platform**: Web (Dokploy deployment)
|
|
||||||
**Project Type**: Web application (Filament admin panel)
|
|
||||||
|
|
||||||
**Performance Goals**:
|
|
||||||
- Process 100 items in <2 minutes (queued)
|
|
||||||
- Handle up to 500 items per operation without timeout
|
|
||||||
- Progress notifications update every 5-10 seconds
|
|
||||||
|
|
||||||
**Constraints**:
|
|
||||||
- Queue jobs MUST process in chunks of 10-20 items (memory efficiency)
|
|
||||||
- Progress tracking requires explicit polling (not automatic in Filament)
|
|
||||||
- Type-to-confirm required for ≥20 destructive items
|
|
||||||
- Tenant isolation enforced at job level
|
|
||||||
|
|
||||||
**Scale/Scope**:
|
|
||||||
- 4 primary resources (Policies, PolicyVersions, BackupSets, RestoreRuns)
|
|
||||||
- 8-12 bulk actions (P1/P2 priority)
|
|
||||||
- Estimated 26-34 hours implementation (3 phases for P1/P2)
|
|
||||||
|
|
||||||
## Constitution Check
|
## Constitution Check
|
||||||
|
|
||||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
**Note**: Project constitution is template-only (not populated). Using Laravel/TenantPilot conventions instead.
|
The constitution file at `.specify/memory/constitution.md` is a placeholder template (no concrete principles/gates are defined). For this feature, the effective gates follow repository agent guidelines in `Agents.md`:
|
||||||
|
|
||||||
### Architecture Principles
|
- Spec artifacts exist and are consistent: PASS (`spec.md`, `plan.md`, `tasks.md`, `research.md`, `data-model.md`, `quickstart.md`)
|
||||||
|
- Tests cover changes: PASS (Pest suite; full test run exits 0)
|
||||||
|
- Safe admin operations: PASS (explicit confirmations, type-to-confirm for large destructive ops, audit logging)
|
||||||
|
|
||||||
✅ **Library-First**: N/A (feature extends existing app, no new libraries)
|
Re-check after Phase 1: PASS (no new unknowns introduced).
|
||||||
✅ **Test-First**: TDD enforced - Pest tests required before implementation
|
|
||||||
✅ **Simplicity**: Uses existing patterns (Jobs, Filament bulk actions, Livewire polling)
|
|
||||||
✅ **Sail-First**: Local development uses Laravel Sail (Docker)
|
|
||||||
✅ **Dokploy Deployment**: Production/staging via Dokploy (VPS containers)
|
|
||||||
|
|
||||||
### Laravel Conventions
|
|
||||||
|
|
||||||
✅ **PSR-12**: Code formatting enforced via Laravel Pint
|
|
||||||
✅ **Eloquent-First**: No raw DB queries, use Model::query() patterns
|
|
||||||
✅ **Permission Gates**: Leverage existing RBAC (Feature 001)
|
|
||||||
✅ **Queue Jobs**: Use ShouldQueue interface, chunked processing
|
|
||||||
✅ **Audit Logging**: Extend existing AuditLog model/service
|
|
||||||
|
|
||||||
### Safety Requirements
|
|
||||||
|
|
||||||
✅ **Tenant Isolation**: Job constructor accepts explicit `tenantId`
|
|
||||||
✅ **Audit Trail**: One audit log entry per bulk operation + per-item outcomes
|
|
||||||
✅ **Confirmation**: Type-to-confirm for ≥20 destructive items
|
|
||||||
✅ **Fail-Soft**: Continue processing on individual failures, abort if >50% fail
|
|
||||||
✅ **Immutability**: Policy Versions check eligibility before prune (referenced, current, age)
|
|
||||||
|
|
||||||
### Gates
|
|
||||||
|
|
||||||
🔒 **GATE-01**: Bulk operations MUST use existing permission model (policies.delete, etc.)
|
|
||||||
🔒 **GATE-02**: Progress tracking MUST use BulkOperationRun model (not fire-and-forget)
|
|
||||||
🔒 **GATE-03**: Type-to-confirm MUST be case-sensitive "DELETE" for ≥20 items
|
|
||||||
🔒 **GATE-04**: Policies bulk delete = local only (ignored_at flag, NO Graph DELETE)
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@ -84,180 +40,42 @@ ### Documentation (this feature)
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
specs/005-bulk-operations/
|
specs/005-bulk-operations/
|
||||||
├── plan.md # This file
|
├── plan.md # This file (/speckit.plan command output)
|
||||||
├── research.md # Phase 0 output (see below)
|
├── research.md # Phase 0 output (/speckit.plan command)
|
||||||
├── data-model.md # Phase 1 output (see below)
|
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||||
├── quickstart.md # Phase 1 output (see below)
|
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT YET CREATED)
|
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Source Code (repository root)
|
### Source Code (repository root)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
app/
|
app/
|
||||||
├── Models/
|
|
||||||
│ ├── BulkOperationRun.php # NEW: Tracks progress/outcomes
|
|
||||||
│ ├── Policy.php # EXTEND: Add markIgnored() scope
|
|
||||||
│ ├── PolicyVersion.php # EXTEND: Add pruneEligible() scope
|
|
||||||
│ ├── BackupSet.php # EXTEND: Cascade delete logic
|
|
||||||
│ └── RestoreRun.php # EXTEND: Skip running status
|
|
||||||
│
|
|
||||||
├── Jobs/
|
|
||||||
│ ├── BulkPolicyDeleteJob.php # NEW: Async bulk delete (local)
|
|
||||||
│ ├── BulkPolicyExportJob.php # NEW: Export to backup set
|
|
||||||
│ ├── BulkPolicyVersionPruneJob.php # NEW: Prune old versions
|
|
||||||
│ ├── BulkBackupSetDeleteJob.php # NEW: Delete backup sets
|
|
||||||
│ └── BulkRestoreRunDeleteJob.php # NEW: Delete restore runs
|
|
||||||
│
|
|
||||||
├── Services/
|
|
||||||
│ ├── BulkOperationService.php # NEW: Orchestrates bulk ops + tracking
|
|
||||||
│ └── Audit/
|
|
||||||
│ └── AuditLogger.php # EXTEND: Add bulk operation events
|
|
||||||
│
|
|
||||||
├── Filament/
|
├── Filament/
|
||||||
│ └── Resources/
|
│ └── Resources/
|
||||||
│ ├── PolicyResource.php # EXTEND: Add bulk actions
|
├── Jobs/
|
||||||
│ ├── PolicyVersionResource.php # EXTEND: Add bulk prune
|
├── Models/
|
||||||
│ ├── BackupSetResource.php # EXTEND: Add bulk delete
|
└── Services/
|
||||||
│ └── RestoreRunResource.php # EXTEND: Add bulk delete
|
|
||||||
│
|
|
||||||
└── Livewire/
|
|
||||||
└── BulkOperationProgress.php # NEW: Progress polling component
|
|
||||||
|
|
||||||
database/
|
database/
|
||||||
|
├── factories/
|
||||||
└── migrations/
|
└── migrations/
|
||||||
└── YYYY_MM_DD_create_bulk_operation_runs_table.php # NEW
|
|
||||||
|
routes/
|
||||||
|
├── web.php
|
||||||
|
└── console.php
|
||||||
|
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
|
||||||
tests/
|
tests/
|
||||||
├── Unit/
|
├── Feature/
|
||||||
│ ├── BulkPolicyDeleteJobTest.php
|
└── Unit/
|
||||||
│ ├── BulkActionPermissionTest.php
|
|
||||||
│ └── BulkEligibilityCheckTest.php
|
|
||||||
│
|
|
||||||
└── Feature/
|
|
||||||
├── BulkDeletePoliciesTest.php
|
|
||||||
├── BulkExportToBackupTest.php
|
|
||||||
├── BulkProgressNotificationTest.php
|
|
||||||
└── BulkTypeToConfirmTest.php
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Structure Decision**: Single web application structure (Laravel + Filament). New bulk operations extend existing Resources with BulkAction definitions. New BulkOperationRun model tracks async job progress. No separate API layer needed (Livewire polling uses Filament infolists/resource pages).
|
**Structure Decision**: Web application (Laravel + Filament admin panel) using existing repository layout.
|
||||||
|
|
||||||
## Complexity Tracking
|
## Complexity Tracking
|
||||||
|
|
||||||
> No constitution violations requiring justification.
|
No constitution violations requiring justification.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 0: Research & Technology Decisions
|
|
||||||
|
|
||||||
See [research.md](./research.md) for detailed research findings.
|
|
||||||
|
|
||||||
### Key Decisions Summary
|
|
||||||
|
|
||||||
| Decision | Chosen | Rationale |
|
|
||||||
|----------|--------|-----------|
|
|
||||||
| Progress tracking | BulkOperationRun model + Livewire polling | Explicit state, survives page refresh, queryable outcomes |
|
|
||||||
| Job chunking | collect()->chunk(10) | Simple, memory-efficient, easy to test |
|
|
||||||
| Type-to-confirm | Filament form + validation rule | Built-in UI, reusable pattern |
|
|
||||||
| Tenant isolation | Explicit tenantId param | Fail-safe, auditable, no reliance on global scopes |
|
|
||||||
| Policy deletion | ignored_at flag | Prevents re-sync, restorable, doesn't touch Intune |
|
|
||||||
| Eligibility checks | Eloquent scopes | Reusable, testable, composable |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: Data Model & Contracts
|
|
||||||
|
|
||||||
See [data-model.md](./data-model.md) for detailed schemas and entity diagrams.
|
|
||||||
|
|
||||||
### Core Entities
|
|
||||||
|
|
||||||
**BulkOperationRun** (NEW):
|
|
||||||
- Tracks progress, outcomes, failures for bulk operations
|
|
||||||
- Fields: resource, action, status, total_items, processed_items, succeeded, failed, skipped
|
|
||||||
- JSONB: item_ids, failures
|
|
||||||
- Relationships: tenant, user, auditLog
|
|
||||||
|
|
||||||
**Policy** (EXTEND):
|
|
||||||
- Add `ignored_at` timestamp (prevents re-sync)
|
|
||||||
- Add `markIgnored()` method and `notIgnored()` scope
|
|
||||||
|
|
||||||
**PolicyVersion** (EXTEND):
|
|
||||||
- Add `pruneEligible()` scope (checks age, references, current status)
|
|
||||||
|
|
||||||
**RestoreRun** (EXTEND):
|
|
||||||
- Add `deletable()` scope (filters by completed/failed status)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Implementation Tasks
|
|
||||||
|
|
||||||
Detailed tasks will be generated via `/speckit.tasks` command. High-level phases:
|
|
||||||
|
|
||||||
### Phase 2.1: Foundation (P1 - Policies) - 8-12 hours
|
|
||||||
- BulkOperationRun migration + model
|
|
||||||
- Policies: ignored_at column, bulk delete/export jobs
|
|
||||||
- Filament bulk actions + type-to-confirm
|
|
||||||
- BulkOperationService orchestration
|
|
||||||
- Tests (unit, feature)
|
|
||||||
|
|
||||||
### Phase 2.2: Progress Tracking (P1) - 8-10 hours
|
|
||||||
- Livewire progress component
|
|
||||||
- Job progress updates (chunked)
|
|
||||||
- Circuit breaker (>50% fail abort)
|
|
||||||
- Audit logging integration
|
|
||||||
- Tests (progress, polling, audit)
|
|
||||||
|
|
||||||
### Phase 2.3: Additional Resources (P2) - 6-8 hours
|
|
||||||
- PolicyVersion prune (eligibility scope)
|
|
||||||
- BackupSet bulk delete
|
|
||||||
- RestoreRun bulk delete
|
|
||||||
- Resource extensions
|
|
||||||
- Tests for each resource
|
|
||||||
|
|
||||||
### Phase 2.4: Polish & Deployment - 4-6 hours
|
|
||||||
- Manual QA (type-to-confirm, progress UI)
|
|
||||||
- Load testing (500 items)
|
|
||||||
- Documentation updates
|
|
||||||
- Staging → Production deployment
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Risk Mitigation
|
|
||||||
|
|
||||||
| Risk | Mitigation |
|
|
||||||
|------|------------|
|
|
||||||
| Queue timeouts | Chunk processing (10-20 items), timeout config (300s), circuit breaker |
|
|
||||||
| Progress polling overhead | Limit interval (5s), index queries, cache recent runs |
|
|
||||||
| Accidental deletes | Type-to-confirm ≥20 items, `ignored_at` flag (restorable), audit trail |
|
|
||||||
| Job crashes | Fail-soft, BulkOperationRun status tracking, Laravel retry |
|
|
||||||
| Eligibility misses | Conservative JSONB queries, manual review before hard delete |
|
|
||||||
| Sync re-adds policies | `ignored_at` filter in SyncPoliciesJob |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
- ✅ Bulk delete 100 policies in <2 minutes
|
|
||||||
- ✅ Type-to-confirm prevents accidents (≥20 items)
|
|
||||||
- ✅ Progress updates every 5-10s
|
|
||||||
- ✅ Audit log captures per-item outcomes
|
|
||||||
- ✅ 95%+ operation success rate
|
|
||||||
- ✅ All P1/P2 tests pass
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. ✅ Generate plan.md (this file)
|
|
||||||
2. → Generate research.md (detailed technology findings)
|
|
||||||
3. → Generate data-model.md (schemas + diagrams)
|
|
||||||
4. → Generate quickstart.md (developer onboarding)
|
|
||||||
5. → Run `/speckit.tasks` to create task breakdown
|
|
||||||
6. → Begin Phase 2.1 implementation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Status**: Plan Complete - Ready for Research
|
|
||||||
**Created**: 2025-12-22
|
|
||||||
**Last Updated**: 2025-12-22
|
|
||||||
|
|||||||
@ -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
|
## Manual Testing Workflow
|
||||||
|
|
||||||
### Scenario 1: Bulk Delete Policies (< 20 items)
|
### Scenario 1: Bulk Delete Policies (< 20 items)
|
||||||
|
|||||||
@ -90,16 +90,16 @@ ### User Story 3 - Bulk Delete Policy Versions (Priority: P2)
|
|||||||
|
|
||||||
**Acceptance Criteria:**
|
**Acceptance Criteria:**
|
||||||
1. **Given** I select 30 policy versions older than 90 days,
|
1. **Given** I select 30 policy versions older than 90 days,
|
||||||
**When** I click "Delete",
|
**When** I click "Prune (Archive)",
|
||||||
**Then** confirmation dialog: "Delete 30 policy versions? This is permanent and cannot be undone."
|
**Then** confirmation dialog: "Archive 30 policy versions? Archived versions can be restored until force-deleted."
|
||||||
|
|
||||||
2. **Given** I confirm,
|
2. **Given** I confirm,
|
||||||
**When** the operation completes,
|
**When** the operation completes,
|
||||||
**Then**:
|
**Then**:
|
||||||
- System checks each version: is_current=false + not referenced + age >90 days
|
- System checks each version: is_current=false + not referenced + age >90 days
|
||||||
- Eligible versions are hard-deleted
|
- Eligible versions are archived (soft-deleted)
|
||||||
- Ineligible versions are skipped with reason (e.g., "Referenced by Backup Set ID 5")
|
- Ineligible versions are skipped with reason (e.g., "Referenced by Backup Set ID 5")
|
||||||
- Success notification: "Deleted 28 policy versions (2 skipped)"
|
- Success notification: "Archived 28 policy versions (2 skipped)"
|
||||||
- Audit log: `policy_versions.bulk_pruned` with version IDs + skip reasons
|
- Audit log: `policy_versions.bulk_pruned` with version IDs + skip reasons
|
||||||
|
|
||||||
3. **Given** I lack `policy_versions.prune` permission,
|
3. **Given** I lack `policy_versions.prune` permission,
|
||||||
@ -299,15 +299,21 @@ ### Policy Versions Resource
|
|||||||
|
|
||||||
| Action | Priority | Destructive | Threshold for Queue | Type-to-Confirm |
|
| Action | Priority | Destructive | Threshold for Queue | Type-to-Confirm |
|
||||||
|--------|----------|-------------|---------------------|-----------------|
|
|--------|----------|-------------|---------------------|-----------------|
|
||||||
| Delete | P2 | Yes | ≥20 | ≥20 |
|
| Prune (archive) | P2 | Yes (local) | ≥20 | ≥20 |
|
||||||
|
| Restore (unarchive) | P2 | No | ≥20 | No |
|
||||||
|
| Force delete (permanent) | P2 | Yes (permanent) | ≥20 | ≥20 |
|
||||||
| Export to Backup | P3 | No | ≥20 | No |
|
| Export to Backup | P3 | No | ≥20 | No |
|
||||||
|
|
||||||
**FR-005.20**: Bulk Delete for Policy Versions MUST:
|
**FR-005.20**: Bulk Prune (Archive) for Policy Versions MUST:
|
||||||
- Check eligibility: `is_current = false` AND `created_at < NOW() - 90 days` AND NOT referenced
|
- Check eligibility: `is_current = false` AND `created_at < NOW() - 90 days` AND NOT referenced
|
||||||
- Referenced = exists in `backup_items.policy_version_id` OR `restore_runs.metadata` OR critical audit logs
|
- Referenced = exists in `backup_items.policy_version_id` OR `restore_runs.metadata` OR critical audit logs
|
||||||
- Hard-delete eligible versions
|
- Archive (soft-delete) eligible versions
|
||||||
- Skip ineligible with reason: "Referenced", "Too recent", "Current version"
|
- Skip ineligible with reason: "Referenced", "Too recent", "Current version"
|
||||||
|
|
||||||
|
**FR-005.20a**: System MUST provide bulk restore for archived Policy Versions (restore only if archived).
|
||||||
|
|
||||||
|
**FR-005.20b**: System MUST provide bulk force delete for archived Policy Versions (permanent).
|
||||||
|
|
||||||
**FR-005.21**: System MUST require `policy_versions.prune` permission (separate from `policy_versions.delete`).
|
**FR-005.21**: System MUST require `policy_versions.prune` permission (separate from `policy_versions.delete`).
|
||||||
|
|
||||||
### Backup Sets Resource
|
### Backup Sets Resource
|
||||||
|
|||||||
@ -15,12 +15,12 @@ ## Phase 1: Setup (Project Initialization)
|
|||||||
|
|
||||||
**Purpose**: Database schema and base infrastructure for bulk operations
|
**Purpose**: Database schema and base infrastructure for bulk operations
|
||||||
|
|
||||||
- [ ] T001 Create migration for `bulk_operation_runs` table in database/migrations/YYYY_MM_DD_HHMMSS_create_bulk_operation_runs_table.php
|
- [x] T001 Create migration for `bulk_operation_runs` table in database/migrations/YYYY_MM_DD_HHMMSS_create_bulk_operation_runs_table.php
|
||||||
- [ ] T002 Create migration to add `ignored_at` column to policies table in database/migrations/YYYY_MM_DD_HHMMSS_add_ignored_at_to_policies_table.php
|
- [x] T002 Create migration to add `ignored_at` column to policies table in database/migrations/YYYY_MM_DD_HHMMSS_add_ignored_at_to_policies_table.php
|
||||||
- [ ] T003 [P] Create BulkOperationRun model in app/Models/BulkOperationRun.php
|
- [x] T003 [P] Create BulkOperationRun model in app/Models/BulkOperationRun.php
|
||||||
- [ ] T004 [P] Create BulkOperationService in app/Services/BulkOperationService.php
|
- [x] T004 [P] Create BulkOperationService in app/Services/BulkOperationService.php
|
||||||
- [ ] T005 Run migrations and verify schema: `./vendor/bin/sail artisan migrate`
|
- [x] T005 Run migrations and verify schema: `./vendor/bin/sail artisan migrate`
|
||||||
- [ ] T006 Run Pint formatting: `./vendor/bin/sail composer pint`
|
- [x] T006 Run Pint formatting: `./vendor/bin/sail composer pint`
|
||||||
|
|
||||||
**Checkpoint**: Database ready, base models created
|
**Checkpoint**: Database ready, base models created
|
||||||
|
|
||||||
@ -32,13 +32,13 @@ ## Phase 2: Foundational (Shared Components)
|
|||||||
|
|
||||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||||
|
|
||||||
- [ ] T007 Extend Policy model with `ignored_at` scope and methods in app/Models/Policy.php
|
- [x] T007 Extend Policy model with `ignored_at` scope and methods in app/Models/Policy.php
|
||||||
- [ ] T008 [P] Extend PolicyVersion model with `pruneEligible()` scope in app/Models/PolicyVersion.php
|
- [x] T008 [P] Extend PolicyVersion model with `pruneEligible()` scope in app/Models/PolicyVersion.php
|
||||||
- [ ] T009 [P] Extend RestoreRun model with `deletable()` scope in app/Models/RestoreRun.php
|
- [x] T009 [P] Extend RestoreRun model with `deletable()` scope in app/Models/RestoreRun.php
|
||||||
- [ ] T010 Extend AuditLogger service to support bulk operation events in app/Services/Audit/AuditLogger.php (or equivalent)
|
- [x] T010 Extend AuditLogger service to support bulk operation events in app/Services/Audit/AuditLogger.php (or equivalent)
|
||||||
- [ ] T011 Update SyncPoliciesJob to filter by `ignored_at IS NULL` in app/Jobs/SyncPoliciesJob.php
|
- [x] T011 Update SyncPoliciesJob to filter by `ignored_at IS NULL` in app/Jobs/SyncPoliciesJob.php
|
||||||
- [ ] T012 Create BulkOperationRun factory in database/factories/BulkOperationRunFactory.php
|
- [x] T012 Create BulkOperationRun factory in database/factories/BulkOperationRunFactory.php
|
||||||
- [ ] T013 Create test seeder BulkOperationsTestSeeder in database/seeders/BulkOperationsTestSeeder.php
|
- [x] T013 Create test seeder BulkOperationsTestSeeder in database/seeders/BulkOperationsTestSeeder.php
|
||||||
|
|
||||||
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
|
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
|
||||||
|
|
||||||
@ -52,21 +52,21 @@ ## Phase 3: User Story 1 - Bulk Delete Policies (Priority: P1) 🎯 MVP
|
|||||||
|
|
||||||
### Tests for User Story 1
|
### Tests for User Story 1
|
||||||
|
|
||||||
- [ ] T014 [P] [US1] Write unit test for BulkPolicyDeleteJob in tests/Unit/BulkPolicyDeleteJobTest.php
|
- [x] T014 [P] [US1] Write unit test for BulkPolicyDeleteJob in tests/Unit/BulkPolicyDeleteJobTest.php
|
||||||
- [ ] T015 [P] [US1] Write feature test for bulk delete <20 items (sync) in tests/Feature/BulkDeletePoliciesTest.php
|
- [x] T015 [P] [US1] Write feature test for bulk delete <20 items (sync) in tests/Feature/BulkDeletePoliciesTest.php
|
||||||
- [ ] T016 [P] [US1] Write feature test for bulk delete ≥20 items (async) in tests/Feature/BulkDeletePoliciesAsyncTest.php
|
- [x] T016 [P] [US1] Write feature test for bulk delete ≥20 items (async) in tests/Feature/BulkDeletePoliciesAsyncTest.php
|
||||||
- [ ] T017 [P] [US1] Write permission test for bulk delete in tests/Unit/BulkActionPermissionTest.php
|
- [x] T017 [P] [US1] Write permission test for bulk delete in tests/Unit/BulkActionPermissionTest.php
|
||||||
|
|
||||||
### Implementation for User Story 1
|
### Implementation for User Story 1
|
||||||
|
|
||||||
- [ ] T018 [P] [US1] Create BulkPolicyDeleteJob in app/Jobs/BulkPolicyDeleteJob.php
|
- [x] T018 [P] [US1] Create BulkPolicyDeleteJob in app/Jobs/BulkPolicyDeleteJob.php
|
||||||
- [ ] T019 [US1] Add bulk delete action to PolicyResource in app/Filament/Resources/PolicyResource.php
|
- [x] T019 [US1] Add bulk delete action to PolicyResource in app/Filament/Resources/PolicyResource.php
|
||||||
- [ ] T020 [US1] Implement type-to-confirm modal (≥20 items) in PolicyResource bulk action
|
- [x] T020 [US1] Implement type-to-confirm modal (≥20 items) in PolicyResource bulk action
|
||||||
- [ ] T021 [US1] Wire BulkOperationService to create tracking record and dispatch job
|
- [x] T021 [US1] Wire BulkOperationService to create tracking record and dispatch job
|
||||||
- [ ] T022 [US1] Test bulk delete with 10 policies (sync, manual QA)
|
- [x] T022 [US1] Test bulk delete with 10 policies (sync, manual QA)
|
||||||
- [ ] T023 [US1] Test bulk delete with 25 policies (async, manual QA)
|
- [x] T023 [US1] Test bulk delete with 25 policies (async, manual QA)
|
||||||
- [ ] T024 [US1] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeletePoliciesTest.php`
|
- [x] T024 [US1] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeletePoliciesTest.php`
|
||||||
- [ ] T025 [US1] Verify audit log entry created with correct metadata
|
- [x] T025 [US1] Verify audit log entry created with correct metadata
|
||||||
|
|
||||||
**Checkpoint**: Bulk delete policies working (sync + async), audit logged, tests passing
|
**Checkpoint**: Bulk delete policies working (sync + async), audit logged, tests passing
|
||||||
|
|
||||||
@ -80,24 +80,43 @@ ## Phase 4: User Story 2 - Bulk Export Policies to Backup (Priority: P1)
|
|||||||
|
|
||||||
### Tests for User Story 2
|
### Tests for User Story 2
|
||||||
|
|
||||||
- [ ] T026 [P] [US2] Write unit test for BulkPolicyExportJob in tests/Unit/BulkPolicyExportJobTest.php
|
- [x] T026 [P] [US2] Write unit test for BulkPolicyExportJob in tests/Unit/BulkPolicyExportJobTest.php
|
||||||
- [ ] T027 [P] [US2] Write feature test for bulk export in tests/Feature/BulkExportToBackupTest.php
|
- [x] T027 [P] [US2] Write feature test for bulk export in tests/Feature/BulkExportToBackupTest.php
|
||||||
- [ ] T028 [P] [US2] Write test for export with failures (3/25 fail) in tests/Feature/BulkExportFailuresTest.php
|
- [x] T028 [P] [US2] Write test for export with failures (3/25 fail) in tests/Feature/BulkExportFailuresTest.php
|
||||||
|
|
||||||
### Implementation for User Story 2
|
### Implementation for User Story 2
|
||||||
|
|
||||||
- [ ] T029 [P] [US2] Create BulkPolicyExportJob in app/Jobs/BulkPolicyExportJob.php
|
- [x] T029 [P] [US2] Create BulkPolicyExportJob in app/Jobs/BulkPolicyExportJob.php
|
||||||
- [ ] T030 [US2] Add bulk export action to PolicyResource in app/Filament/Resources/PolicyResource.php
|
- [x] T030 [US2] Add bulk export action to PolicyResource in app/Filament/Resources/PolicyResource.php
|
||||||
- [ ] T031 [US2] Create export form with backup_name and include_assignments fields
|
- [x] T031 [US2] Create export form with backup_name and include_assignments fields
|
||||||
- [ ] T032 [US2] Implement export logic (create BackupSet, capture BackupItems)
|
- [x] T032 [US2] Implement export logic (create BackupSet, capture BackupItems)
|
||||||
- [ ] T033 [US2] Handle partial failures (some policies fail to backup)
|
- [x] T033 [US2] Handle partial failures (some policies fail to backup)
|
||||||
- [ ] T034 [US2] Test export with 30 policies (manual QA)
|
- [x] T034 [US2] Test export with 30 policies (manual QA)
|
||||||
- [ ] T035 [US2] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkExportToBackupTest.php`
|
- [x] T035 [US2] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkExportToBackupTest.php`
|
||||||
|
|
||||||
**Checkpoint**: Bulk export working, BackupSets created, failures handled gracefully
|
**Checkpoint**: Bulk export working, BackupSets created, failures handled gracefully
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 4b: Requirement - Bulk Sync Policies (FR-005.19)
|
||||||
|
|
||||||
|
**Goal**: Enable admins to queue a sync (re-fetch) for selected policies in one action.
|
||||||
|
|
||||||
|
**Independent Test**: Select 25 policies → bulk sync → verify job(s) queued and progress/finish notifications.
|
||||||
|
|
||||||
|
### Tests for Bulk Sync Policies
|
||||||
|
|
||||||
|
- [x] T035a [P] Write feature test for bulk sync dispatch in tests/Feature/BulkSyncPoliciesTest.php
|
||||||
|
- [x] T035b [P] Write permission test for bulk sync action in tests/Unit/BulkActionPermissionTest.php
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
- [x] T035e Manual QA: bulk sync 25 policies (verify queued processing + notifications)
|
||||||
|
|
||||||
|
**Checkpoint**: Bulk sync action queues work and respects permissions
|
||||||
|
|
||||||
## Phase 5: User Story 5 - Type-to-Confirm (Priority: P1)
|
## Phase 5: User Story 5 - Type-to-Confirm (Priority: P1)
|
||||||
|
|
||||||
**Goal**: Require typing "DELETE" for destructive operations with ≥20 items
|
**Goal**: Require typing "DELETE" for destructive operations with ≥20 items
|
||||||
@ -108,16 +127,16 @@ ## Phase 5: User Story 5 - Type-to-Confirm (Priority: P1)
|
|||||||
|
|
||||||
### Tests for User Story 5
|
### Tests for User Story 5
|
||||||
|
|
||||||
- [ ] T036 [P] [US5] Write test for type-to-confirm with correct input in tests/Feature/BulkTypeToConfirmTest.php
|
- [x] T036 [P] [US5] Write test for type-to-confirm with correct input in tests/Feature/BulkTypeToConfirmTest.php
|
||||||
- [ ] T037 [P] [US5] Write test for type-to-confirm with incorrect input (lowercase "delete")
|
- [x] T037 [P] [US5] Write test for type-to-confirm with incorrect input (lowercase "delete")
|
||||||
- [ ] T038 [P] [US5] Write test for type-to-confirm disabled for <20 items
|
- [x] T038 [P] [US5] Write test for type-to-confirm disabled for <20 items
|
||||||
|
|
||||||
### Validation for User Story 5
|
### Validation for User Story 5
|
||||||
|
|
||||||
- [ ] T039 [US5] Manual test: Bulk delete 10 policies → confirm without typing (should work)
|
- [x] 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
|
- [x] 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] T041 [US5] Manual test: Bulk delete 25 policies → type "DELETE" → button enables, operation proceeds
|
||||||
- [ ] T042 [US5] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkTypeToConfirmTest.php`
|
- [x] T042 [US5] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkTypeToConfirmTest.php`
|
||||||
|
|
||||||
**Checkpoint**: Type-to-confirm working correctly for all thresholds
|
**Checkpoint**: Type-to-confirm working correctly for all thresholds
|
||||||
|
|
||||||
@ -131,21 +150,25 @@ ## Phase 6: User Story 6 - Progress Tracking (Priority: P2)
|
|||||||
|
|
||||||
### Tests for User Story 6
|
### Tests for User Story 6
|
||||||
|
|
||||||
- [ ] T043 [P] [US6] Write test for progress updates in BulkOperationRun in tests/Unit/BulkOperationRunProgressTest.php
|
- [x] T043 [P] [US6] Write test for progress updates in BulkOperationRun in tests/Unit/BulkOperationRunProgressTest.php
|
||||||
- [ ] T044 [P] [US6] Write feature test for progress notification in tests/Feature/BulkProgressNotificationTest.php
|
- [x] T044 [P] [US6] Write feature test for progress notification in tests/Feature/BulkProgressNotificationTest.php
|
||||||
- [ ] T045 [P] [US6] Write test for circuit breaker (abort >50% fail) in tests/Unit/CircuitBreakerTest.php
|
- [x] T045 [P] [US6] Write test for circuit breaker (abort >50% fail) in tests/Unit/CircuitBreakerTest.php
|
||||||
|
|
||||||
### Implementation for User Story 6
|
### Implementation for User Story 6
|
||||||
|
|
||||||
- [ ] T046 [P] [US6] Create Livewire progress component in app/Livewire/BulkOperationProgress.php
|
- [x] T046 [P] [US6] Create Livewire progress component in app/Livewire/BulkOperationProgress.php
|
||||||
- [ ] T047 [P] [US6] Create progress view in resources/views/livewire/bulk-operation-progress.blade.php
|
- [x] T047 [P] [US6] Create progress view in resources/views/livewire/bulk-operation-progress.blade.php
|
||||||
- [ ] T048 [US6] Update BulkPolicyDeleteJob to emit progress after each chunk
|
- [x] T048 [US6] Update BulkPolicyDeleteJob to emit progress after each chunk
|
||||||
- [ ] T049 [US6] Update BulkPolicyExportJob to emit progress after each chunk
|
- [x] T049 [US6] Update BulkPolicyExportJob to emit progress after each chunk
|
||||||
- [ ] T050 [US6] Implement circuit breaker logic (abort if >50% fail) in jobs
|
- [x] T050 [US6] Implement circuit breaker logic (abort if >50% fail) in jobs
|
||||||
- [ ] T051 [US6] Add progress polling to Filament notifications or sidebar widget
|
- [x] T051 [US6] Add progress polling to Filament notifications or sidebar widget
|
||||||
- [ ] T052 [US6] Test progress with 100 policies (manual QA, observe updates)
|
- [x] T052 [US6] Test progress with 100 policies (manual QA, observe updates)
|
||||||
- [ ] T053 [US6] Test circuit breaker with mock failures (manual QA)
|
- [x] T053 [US6] Test circuit breaker with mock failures (manual QA)
|
||||||
- [ ] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php`
|
- [x] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php`
|
||||||
|
|
||||||
|
- [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
|
**Checkpoint**: Progress tracking working, polling updates UI, circuit breaker aborts high-failure jobs
|
||||||
|
|
||||||
@ -155,25 +178,33 @@ ## Phase 7: User Story 3 - Bulk Delete Policy Versions (Priority: P2)
|
|||||||
|
|
||||||
**Goal**: Enable admins to prune old policy versions that are NOT referenced and meet retention threshold (>90 days)
|
**Goal**: Enable admins to prune old policy versions that are NOT referenced and meet retention threshold (>90 days)
|
||||||
|
|
||||||
**Independent Test**: Select 30 old versions → bulk prune → verify eligible deleted, ineligible skipped with reasons
|
**Independent Test**: Select 30 old versions → bulk prune → verify eligible archived, ineligible skipped with reasons
|
||||||
|
|
||||||
### Tests for User Story 3
|
### Tests for User Story 3
|
||||||
|
|
||||||
- [ ] T055 [P] [US3] Write unit test for pruneEligible() scope in tests/Unit/PolicyVersionEligibilityTest.php
|
- [x] T055 [P] [US3] Write unit test for pruneEligible() scope in tests/Unit/PolicyVersionEligibilityTest.php
|
||||||
- [ ] T056 [P] [US3] Write unit test for BulkPolicyVersionPruneJob in tests/Unit/BulkPolicyVersionPruneJobTest.php
|
- [x] T056 [P] [US3] Write unit test for BulkPolicyVersionPruneJob in tests/Unit/BulkPolicyVersionPruneJobTest.php
|
||||||
- [ ] T057 [P] [US3] Write feature test for bulk prune in tests/Feature/BulkPruneVersionsTest.php
|
- [x] T057 [P] [US3] Write feature test for bulk prune in tests/Feature/BulkPruneVersionsTest.php
|
||||||
- [ ] T058 [P] [US3] Write test for skip reasons (referenced, too recent, current) in tests/Feature/BulkPruneSkipReasonsTest.php
|
- [x] T058 [P] [US3] Write test for skip reasons (referenced, too recent, current) in tests/Feature/BulkPruneSkipReasonsTest.php
|
||||||
|
|
||||||
### Implementation for User Story 3
|
### Implementation for User Story 3
|
||||||
|
|
||||||
- [ ] T059 [P] [US3] Create BulkPolicyVersionPruneJob in app/Jobs/BulkPolicyVersionPruneJob.php
|
- [x] T059 [P] [US3] Create BulkPolicyVersionPruneJob in app/Jobs/BulkPolicyVersionPruneJob.php
|
||||||
- [ ] T060 [US3] Add bulk delete action to PolicyVersionResource in app/Filament/Resources/PolicyVersionResource.php
|
- [x] T060 [US3] Add bulk delete action to PolicyVersionResource in app/Filament/Resources/PolicyVersionResource.php
|
||||||
- [ ] T061 [US3] Implement eligibility check per version in job (reuse pruneEligible scope)
|
- [x] T061 [US3] Implement eligibility check per version in job (reuse pruneEligible scope)
|
||||||
- [ ] T062 [US3] Collect skip reasons for ineligible versions
|
- [x] T062 [US3] Collect skip reasons for ineligible versions
|
||||||
- [ ] T063 [US3] Add type-to-confirm for ≥20 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
|
||||||
- [ ] T065 [US3] Verify skip reasons in notification and audit log
|
- [x] T065 [US3] Verify skip reasons in notification and audit log
|
||||||
- [ ] T066 [US3] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkPruneVersionsTest.php`
|
- [x] T066 [US3] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkPruneVersionsTest.php`
|
||||||
|
|
||||||
|
- [x] T066a [US3] Add bulk force delete versions job in app/Jobs/BulkPolicyVersionForceDeleteJob.php
|
||||||
|
- [x] T066b [US3] Add bulk force delete action to PolicyVersionResource in app/Filament/Resources/PolicyVersionResource.php
|
||||||
|
- [x] T066c [US3] Write unit+feature tests for bulk force delete in tests/Unit/BulkPolicyVersionForceDeleteJobTest.php and tests/Feature/BulkForceDeletePolicyVersionsTest.php
|
||||||
|
|
||||||
|
- [x] T066d [US3] Add bulk restore versions job in app/Jobs/BulkPolicyVersionRestoreJob.php
|
||||||
|
- [x] T066e [US3] Add restore actions (row + bulk) to PolicyVersionResource in app/Filament/Resources/PolicyVersionResource.php
|
||||||
|
- [x] T066f [US3] Write unit+feature tests for bulk restore in tests/Unit/BulkPolicyVersionRestoreJobTest.php and tests/Feature/BulkRestorePolicyVersionsTest.php
|
||||||
|
|
||||||
**Checkpoint**: Policy versions pruning working, eligibility enforced, skip reasons logged
|
**Checkpoint**: Policy versions pruning working, eligibility enforced, skip reasons logged
|
||||||
|
|
||||||
@ -187,20 +218,28 @@ ## Phase 8: User Story 4 - Bulk Delete Restore Runs (Priority: P2)
|
|||||||
|
|
||||||
### Tests for User Story 4
|
### Tests for User Story 4
|
||||||
|
|
||||||
- [ ] T067 [P] [US4] Write unit test for deletable() scope in tests/Unit/RestoreRunDeletableTest.php
|
- [x] 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
|
- [x] 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
|
- [x] 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] T070 [P] [US4] Write test for mixed statuses (skip running) in tests/Feature/BulkDeleteMixedStatusTest.php
|
||||||
|
|
||||||
### Implementation for User Story 4
|
### Implementation for User Story 4
|
||||||
|
|
||||||
- [ ] T071 [P] [US4] Create BulkRestoreRunDeleteJob in app/Jobs/BulkRestoreRunDeleteJob.php
|
- [x] T071 [P] [US4] Create BulkRestoreRunDeleteJob in app/Jobs/BulkRestoreRunDeleteJob.php
|
||||||
- [ ] T072 [US4] Add bulk delete action to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php
|
- [x] T072 [US4] Add bulk delete action to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php
|
||||||
- [ ] T073 [US4] Filter by deletable() scope (completed, failed, aborted only)
|
- [x] T073 [US4] Filter by deletable() scope (completed, failed, aborted only)
|
||||||
- [ ] T074 [US4] Skip running restore runs with warning
|
- [x] T074 [US4] Skip running restore runs with warning
|
||||||
- [ ] T075 [US4] Add type-to-confirm for ≥20 runs
|
- [x] T075 [US4] Add type-to-confirm for ≥20 runs
|
||||||
- [ ] T076 [US4] Test delete with 20 completed + 5 running (manual QA, verify 5 skipped)
|
- [x] 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] 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
|
**Checkpoint**: Restore runs bulk delete working, running runs protected, skip warnings shown
|
||||||
|
|
||||||
@ -214,17 +253,32 @@ ## Phase 9: Additional Resource - Bulk Delete Backup Sets (Priority: P2)
|
|||||||
|
|
||||||
### Tests for Additional Resource
|
### Tests for Additional Resource
|
||||||
|
|
||||||
- [ ] T078 [P] Write unit test for BulkBackupSetDeleteJob in tests/Unit/BulkBackupSetDeleteJobTest.php
|
- [x] 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] T079 [P] Write feature test for bulk delete backup sets in tests/Feature/BulkDeleteBackupSetsTest.php
|
||||||
|
|
||||||
### Implementation for Additional Resource
|
### Implementation for Additional Resource
|
||||||
|
|
||||||
- [ ] T080 [P] Create BulkBackupSetDeleteJob in app/Jobs/BulkBackupSetDeleteJob.php
|
- [x] T080 [P] Create BulkBackupSetDeleteJob in app/Jobs/BulkBackupSetDeleteJob.php
|
||||||
- [ ] T081 Add bulk delete action to BackupSetResource in app/Filament/Resources/BackupSetResource.php
|
- [x] 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)
|
- [x] T082 Verify cascade-delete logic for backup items (should be automatic via foreign key)
|
||||||
- [ ] T083 Add type-to-confirm for ≥10 sets
|
- [x] T083 Add type-to-confirm for ≥10 sets
|
||||||
- [ ] T084 Test delete with 15 backup sets (manual QA)
|
- [x] T084 Test delete with 15 backup sets (manual QA)
|
||||||
- [ ] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php`
|
- [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)
|
||||||
|
|
||||||
|
- [ ] T085a Verify BackupSet has `archived_at` column; add migration if missing in database/migrations/
|
||||||
|
- [ ] T085b Add bulk archive action to BackupSetResource in app/Filament/Resources/BackupSetResource.php
|
||||||
|
- [ ] T085c [P] Write tests for bulk archive in tests/Feature/BulkArchiveBackupSetsTest.php (and unit test if a job/service is introduced)
|
||||||
|
- [ ] T085d Manual QA: archive 15 backup sets and verify listing/filter behavior
|
||||||
|
|
||||||
**Checkpoint**: Backup sets bulk delete working, cascade-delete verified
|
**Checkpoint**: Backup sets bulk delete working, cascade-delete verified
|
||||||
|
|
||||||
@ -234,17 +288,17 @@ ## Phase 10: Polish & Cross-Cutting Concerns
|
|||||||
|
|
||||||
**Purpose**: Documentation, cleanup, performance optimization
|
**Purpose**: Documentation, cleanup, performance optimization
|
||||||
|
|
||||||
- [ ] T086 [P] Update README.md with bulk operations feature description
|
- [x] T086 [P] Update README.md with bulk operations feature description
|
||||||
- [ ] T087 [P] Update quickstart.md with manual testing scenarios (already done in planning)
|
- [x] T087 [P] Update quickstart.md with manual testing scenarios (already done in planning)
|
||||||
- [ ] T088 Code cleanup: Remove debug statements, refactor duplicated logic
|
- [ ] T088 Code cleanup: Remove debug statements, refactor duplicated logic
|
||||||
- [ ] T089 Performance test: Bulk delete 500 policies (should complete <5 minutes)
|
- [ ] T089 Performance test: Bulk delete 500 policies (should complete <5 minutes)
|
||||||
- [ ] T090 Load test: Concurrent bulk operations (2-3 admins, different resources)
|
- [ ] T090 Load test: Concurrent bulk operations (2-3 admins, different resources)
|
||||||
- [ ] T091 [P] Security review: Verify tenant isolation in all jobs
|
- [ ] T091 [P] Security review: Verify tenant isolation in all jobs
|
||||||
- [ ] T092 [P] Permission audit: Verify all bulk actions respect RBAC
|
- [ ] T092 [P] Permission audit: Verify all bulk actions respect RBAC
|
||||||
- [ ] T093 Run full test suite: `./vendor/bin/sail artisan test`
|
- [x] T093 Run full test suite: `./vendor/bin/sail artisan test`
|
||||||
- [ ] T094 Run Pint formatting: `./vendor/bin/sail composer pint`
|
- [x] T094 Run Pint formatting: `./vendor/bin/sail composer pint`
|
||||||
- [ ] T095 Manual QA checklist: Complete all scenarios from quickstart.md
|
- [x] T095 Manual QA checklist: Complete all scenarios from quickstart.md
|
||||||
- [ ] T096 Document configuration options (chunk size, polling interval, retention days)
|
- [x] T096 Document configuration options (chunk size, polling interval, retention days)
|
||||||
- [ ] T097 Create BulkOperationRun resource page in Filament (view progress, retry failed)
|
- [ ] T097 Create BulkOperationRun resource page in Filament (view progress, retry failed)
|
||||||
- [ ] T098 Add bulk operation metrics to dashboard (total runs, success rate)
|
- [ ] T098 Add bulk operation metrics to dashboard (total runs, success rate)
|
||||||
|
|
||||||
|
|||||||
113
tests/Feature/BulkDeleteBackupSetsTest.php
Normal file
113
tests/Feature/BulkDeleteBackupSetsTest.php
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\BackupSetResource;
|
||||||
|
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 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();
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
$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 can be archived even when referenced by restore runs', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$set = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$restoreRun = RestoreRun::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $set->id,
|
||||||
|
'status' => 'completed',
|
||||||
|
'is_dry_run' => true,
|
||||||
|
'requested_by' => 'tester@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(BackupSetResource\Pages\ListBackupSets::class)
|
||||||
|
->callTableBulkAction('bulk_delete', collect([$set]))
|
||||||
|
->assertHasNoTableBulkActionErrors();
|
||||||
|
|
||||||
|
expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue();
|
||||||
|
expect(RestoreRun::withTrashed()->find($restoreRun->id))->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('backup sets table bulk archive requires type-to-confirm for 10+ sets', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
$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();
|
||||||
|
});
|
||||||
62
tests/Feature/BulkDeleteMixedStatusTest.php
Normal file
62
tests/Feature/BulkDeleteMixedStatusTest.php
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<?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();
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
$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);
|
||||||
|
});
|
||||||
30
tests/Feature/BulkDeletePoliciesAsyncTest.php
Normal file
30
tests/Feature/BulkDeletePoliciesAsyncTest.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\BulkPolicyDeleteJob;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('bulk delete large batch dispatches async job', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$policies = Policy::factory()->count(25)->create(['tenant_id' => $tenant->id]);
|
||||||
|
$policyIds = $policies->pluck('id')->toArray();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 25);
|
||||||
|
|
||||||
|
// Simulate Async dispatch (this logic will be in Filament Action)
|
||||||
|
BulkPolicyDeleteJob::dispatch($run->id);
|
||||||
|
|
||||||
|
Queue::assertPushed(BulkPolicyDeleteJob::class, function ($job) use ($run) {
|
||||||
|
return $job->bulkRunId === $run->id;
|
||||||
|
});
|
||||||
|
});
|
||||||
34
tests/Feature/BulkDeletePoliciesTest.php
Normal file
34
tests/Feature/BulkDeletePoliciesTest.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\BulkPolicyDeleteJob;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('bulk delete sync execution updates policies immediately', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]);
|
||||||
|
$policyIds = $policies->pluck('id')->toArray();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 10);
|
||||||
|
|
||||||
|
// Simulate Sync execution
|
||||||
|
BulkPolicyDeleteJob::dispatchSync($run->id);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed')
|
||||||
|
->and($run->processed_items)->toBe(10)
|
||||||
|
->and($run->audit_log_id)->not->toBeNull();
|
||||||
|
|
||||||
|
expect(\App\Models\AuditLog::where('action', 'bulk.policy.delete.completed')->exists())->toBeTrue();
|
||||||
|
|
||||||
|
$policies->each(function ($policy) {
|
||||||
|
expect($policy->refresh()->ignored_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
51
tests/Feature/BulkDeleteRestoreRunsTest.php
Normal file
51
tests/Feature/BulkDeleteRestoreRunsTest.php
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?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();
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
$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');
|
||||||
|
});
|
||||||
51
tests/Feature/BulkExportFailuresTest.php
Normal file
51
tests/Feature/BulkExportFailuresTest.php
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\BulkPolicyExportJob;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('bulk export completes with errors when some policies cannot be exported', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$okPolicy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
PolicyVersion::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $okPolicy->id,
|
||||||
|
'policy_type' => $okPolicy->policy_type,
|
||||||
|
'version_number' => 1,
|
||||||
|
'snapshot' => ['ok' => true],
|
||||||
|
'captured_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$missingVersionPolicy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun(
|
||||||
|
$tenant,
|
||||||
|
$user,
|
||||||
|
'policy',
|
||||||
|
'export',
|
||||||
|
[$okPolicy->id, $missingVersionPolicy->id],
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
(new BulkPolicyExportJob($run->id, 'Failures Backup'))->handle($service);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed_with_errors')
|
||||||
|
->and($run->succeeded)->toBe(1)
|
||||||
|
->and($run->failed)->toBe(1)
|
||||||
|
->and($run->processed_items)->toBe(2);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('backup_sets', [
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Failures Backup',
|
||||||
|
]);
|
||||||
|
});
|
||||||
41
tests/Feature/BulkExportToBackupTest.php
Normal file
41
tests/Feature/BulkExportToBackupTest.php
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\BulkPolicyExportJob;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('bulk export creates backup set with items', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
PolicyVersion::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'version_number' => 1,
|
||||||
|
'snapshot' => ['test' => 'data'],
|
||||||
|
'captured_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'export', [$policy->id], 1);
|
||||||
|
|
||||||
|
// Simulate Sync
|
||||||
|
$job = new BulkPolicyExportJob($run->id, 'Feature Backup');
|
||||||
|
$job->handle($service);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('backup_sets', [
|
||||||
|
'name' => 'Feature Backup',
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
56
tests/Feature/BulkForceDeleteBackupSetsTest.php
Normal file
56
tests/Feature/BulkForceDeleteBackupSetsTest.php
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<?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();
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
$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');
|
||||||
|
});
|
||||||
49
tests/Feature/BulkForceDeletePolicyVersionsTest.php
Normal file
49
tests/Feature/BulkForceDeletePolicyVersionsTest.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('policy versions table bulk force delete creates a run and skips non-archived records', function () {
|
||||||
|
$tenant = Tenant::factory()->create(['is_current' => true]);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
$version = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'captured_at' => now()->subDays(120),
|
||||||
|
]);
|
||||||
|
$version->delete();
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(PolicyVersionResource\Pages\ListPolicyVersions::class)
|
||||||
|
->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false)
|
||||||
|
->callTableBulkAction('bulk_force_delete_versions', collect([$version]), data: [
|
||||||
|
'confirmation' => 'DELETE',
|
||||||
|
])
|
||||||
|
->assertHasNoTableBulkActionErrors();
|
||||||
|
|
||||||
|
$run = BulkOperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('resource', 'policy_version')
|
||||||
|
->where('action', 'force_delete')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->succeeded)->toBe(1)
|
||||||
|
->and($run->skipped)->toBe(0)
|
||||||
|
->and($run->failed)->toBe(0);
|
||||||
|
|
||||||
|
expect(PolicyVersion::withTrashed()->whereKey($version->id)->exists())->toBeFalse();
|
||||||
|
});
|
||||||
58
tests/Feature/BulkForceDeleteRestoreRunsTest.php
Normal file
58
tests/Feature/BulkForceDeleteRestoreRunsTest.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?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();
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
$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');
|
||||||
|
});
|
||||||
51
tests/Feature/BulkProgressNotificationTest.php
Normal file
51
tests/Feature/BulkProgressNotificationTest.php
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\BulkOperationProgress;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('progress widget shows running operations for current tenant and user', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
// Own running op
|
||||||
|
BulkOperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'status' => 'running',
|
||||||
|
'resource' => 'policy',
|
||||||
|
'action' => 'delete',
|
||||||
|
'total_items' => 100,
|
||||||
|
'processed_items' => 50,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Completed op (should not show)
|
||||||
|
BulkOperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'status' => 'completed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Other user's op (should not show)
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
BulkOperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'user_id' => $otherUser->id,
|
||||||
|
'status' => 'running',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// $tenant->makeCurrent();
|
||||||
|
$tenant->forceFill(['is_current' => true])->save();
|
||||||
|
|
||||||
|
auth()->login($user); // Login user explicitly for auth()->id() call in component
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(BulkOperationProgress::class)
|
||||||
|
->assertSee('Delete Policy')
|
||||||
|
->assertSee('50 / 100');
|
||||||
|
});
|
||||||
61
tests/Feature/BulkPruneSkipReasonsTest.php
Normal file
61
tests/Feature/BulkPruneSkipReasonsTest.php
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('bulk prune records skip reasons', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policyA = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
$current = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policyA->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'captured_at' => now()->subDays(120),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policyB = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
$tooRecent = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policyB->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'captured_at' => now()->subDays(10),
|
||||||
|
]);
|
||||||
|
PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policyB->id,
|
||||||
|
'version_number' => 2,
|
||||||
|
'captured_at' => now()->subDays(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->forceFill(['is_current' => true])->save();
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(PolicyVersionResource\Pages\ListPolicyVersions::class)
|
||||||
|
->callTableBulkAction('bulk_prune_versions', collect([$current, $tooRecent]), data: [
|
||||||
|
'retention_days' => 90,
|
||||||
|
])
|
||||||
|
->assertHasNoTableBulkActionErrors();
|
||||||
|
|
||||||
|
$run = BulkOperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('resource', 'policy_version')
|
||||||
|
->where('action', 'prune')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
$reasons = collect($run->failures ?? [])->pluck('reason')->all();
|
||||||
|
expect($reasons)->toContain('Current version')
|
||||||
|
->and($reasons)->toContain('Too recent');
|
||||||
|
});
|
||||||
44
tests/Feature/BulkPruneVersionsTest.php
Normal file
44
tests/Feature/BulkPruneVersionsTest.php
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('bulk prune archives eligible policy versions', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
|
||||||
|
$eligible = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'captured_at' => now()->subDays(120),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 2,
|
||||||
|
'captured_at' => now()->subDays(120),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->forceFill(['is_current' => true])->save();
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(PolicyVersionResource\Pages\ListPolicyVersions::class)
|
||||||
|
->callTableBulkAction('bulk_prune_versions', collect([$eligible, $current]), data: [
|
||||||
|
'retention_days' => 90,
|
||||||
|
])
|
||||||
|
->assertHasNoTableBulkActionErrors();
|
||||||
|
|
||||||
|
expect($eligible->refresh()->trashed())->toBeTrue();
|
||||||
|
expect($current->refresh()->trashed())->toBeFalse();
|
||||||
|
});
|
||||||
59
tests/Feature/BulkRestoreBackupSetsTest.php
Normal file
59
tests/Feature/BulkRestoreBackupSetsTest.php
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<?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();
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
$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');
|
||||||
|
});
|
||||||
48
tests/Feature/BulkRestorePolicyVersionsTest.php
Normal file
48
tests/Feature/BulkRestorePolicyVersionsTest.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('policy versions table bulk restore creates a run and restores archived records', function () {
|
||||||
|
$tenant = Tenant::factory()->create(['is_current' => true]);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
$version = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'captured_at' => now()->subDays(120),
|
||||||
|
]);
|
||||||
|
$version->delete();
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(PolicyVersionResource\Pages\ListPolicyVersions::class)
|
||||||
|
->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false)
|
||||||
|
->callTableBulkAction('bulk_restore_versions', collect([$version]))
|
||||||
|
->assertHasNoTableBulkActionErrors();
|
||||||
|
|
||||||
|
$run = BulkOperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('resource', 'policy_version')
|
||||||
|
->where('action', 'restore')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->succeeded)->toBe(1)
|
||||||
|
->and($run->skipped)->toBe(0)
|
||||||
|
->and($run->failed)->toBe(0);
|
||||||
|
|
||||||
|
$version->refresh();
|
||||||
|
expect($version->trashed())->toBeFalse();
|
||||||
|
});
|
||||||
58
tests/Feature/BulkRestoreRestoreRunsTest.php
Normal file
58
tests/Feature/BulkRestoreRestoreRunsTest.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?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();
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
$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();
|
||||||
|
});
|
||||||
93
tests/Feature/BulkSyncPoliciesTest.php
Normal file
93
tests/Feature/BulkSyncPoliciesTest.php
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\BulkPolicySyncJob;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('bulk sync updates selected policies from graph', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policies = Policy::factory()
|
||||||
|
->count(3)
|
||||||
|
->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows10AndLater',
|
||||||
|
'last_synced_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
|
||||||
|
{
|
||||||
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, [
|
||||||
|
'payload' => [
|
||||||
|
'id' => $policyId,
|
||||||
|
'displayName' => "Synced {$policyId}",
|
||||||
|
'platform' => $options['platform'] ?? null,
|
||||||
|
'example' => 'value',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrganization(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'sync', $policies->modelKeys(), 3);
|
||||||
|
|
||||||
|
BulkPolicySyncJob::dispatchSync($run->id);
|
||||||
|
|
||||||
|
$bulkRun = BulkOperationRun::query()->find($run->id);
|
||||||
|
expect($bulkRun)->not->toBeNull();
|
||||||
|
expect($bulkRun->status)->toBe('completed');
|
||||||
|
expect($bulkRun->total_items)->toBe(3);
|
||||||
|
expect($bulkRun->succeeded)->toBe(3);
|
||||||
|
expect($bulkRun->failed)->toBe(0);
|
||||||
|
|
||||||
|
$policies->each(function (Policy $policy) {
|
||||||
|
$policy->refresh();
|
||||||
|
|
||||||
|
expect($policy->last_synced_at)->not->toBeNull();
|
||||||
|
expect($policy->display_name)->toBe("Synced {$policy->external_id}");
|
||||||
|
expect($policy->metadata)->toMatchArray([
|
||||||
|
'example' => 'value',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(AuditLog::where('action', 'bulk.policy.sync.completed')->exists())->toBeTrue();
|
||||||
|
});
|
||||||
56
tests/Feature/BulkTypeToConfirmTest.php
Normal file
56
tests/Feature/BulkTypeToConfirmTest.php
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyResource;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('bulk delete requires confirmation string for large batches', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$policies = Policy::factory()->count(20)->create(['tenant_id' => $tenant->id]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(PolicyResource\Pages\ListPolicies::class)
|
||||||
|
->callTableBulkAction('bulk_delete', $policies, data: [
|
||||||
|
'confirmation' => 'DELETE',
|
||||||
|
])
|
||||||
|
->assertHasNoTableBulkActionErrors();
|
||||||
|
|
||||||
|
$policies->each(fn ($p) => expect($p->refresh()->ignored_at)->not->toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk delete fails with incorrect confirmation string', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$policies = Policy::factory()->count(20)->create(['tenant_id' => $tenant->id]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(PolicyResource\Pages\ListPolicies::class)
|
||||||
|
->callTableBulkAction('bulk_delete', $policies, data: [
|
||||||
|
'confirmation' => 'delete', // lowercase, should fail
|
||||||
|
])
|
||||||
|
->assertHasTableBulkActionErrors(['confirmation']);
|
||||||
|
|
||||||
|
$policies->each(fn ($p) => expect($p->refresh()->ignored_at)->toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk delete does not require confirmation string for small batches', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(PolicyResource\Pages\ListPolicies::class)
|
||||||
|
->callTableBulkAction('bulk_delete', $policies, data: [])
|
||||||
|
->assertHasNoTableBulkActionErrors();
|
||||||
|
|
||||||
|
$policies->each(fn ($p) => expect($p->refresh()->ignored_at)->not->toBeNull());
|
||||||
|
});
|
||||||
41
tests/Feature/BulkUnignorePoliciesTest.php
Normal file
41
tests/Feature/BulkUnignorePoliciesTest.php
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\BulkPolicyUnignoreJob;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('bulk restore (unignore) clears ignored_at for selected policies', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policies = Policy::factory()
|
||||||
|
->count(5)
|
||||||
|
->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'ignored_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policyIds = $policies->pluck('id')->toArray();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'unignore', $policyIds, count($policyIds));
|
||||||
|
|
||||||
|
BulkPolicyUnignoreJob::dispatchSync($run->id);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
|
||||||
|
expect($run->status)->toBe('completed')
|
||||||
|
->and($run->processed_items)->toBe(5)
|
||||||
|
->and($run->audit_log_id)->not->toBeNull();
|
||||||
|
|
||||||
|
expect(\App\Models\AuditLog::where('action', 'bulk.policy.unignore.completed')->exists())->toBeTrue();
|
||||||
|
|
||||||
|
$policies->each(function (Policy $policy): void {
|
||||||
|
expect($policy->refresh()->ignored_at)->toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
|
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
|
||||||
|
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||||
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
|
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
|
||||||
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
||||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||||
@ -53,7 +54,7 @@
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('backup set archive is blocked when restore runs exist', function () {
|
test('backup set can be archived when restore runs exist', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'tenant_id' => 'tenant-2',
|
'tenant_id' => 'tenant-2',
|
||||||
'name' => 'Tenant 2',
|
'name' => 'Tenant 2',
|
||||||
@ -65,7 +66,7 @@
|
|||||||
'status' => 'completed',
|
'status' => 'completed',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
RestoreRun::create([
|
$restoreRun = RestoreRun::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'backup_set_id' => $backupSet->id,
|
'backup_set_id' => $backupSet->id,
|
||||||
'status' => 'completed',
|
'status' => 'completed',
|
||||||
@ -77,12 +78,13 @@
|
|||||||
Livewire::test(ListBackupSets::class)
|
Livewire::test(ListBackupSets::class)
|
||||||
->callTableAction('archive', $backupSet);
|
->callTableAction('archive', $backupSet);
|
||||||
|
|
||||||
$this->assertDatabaseMissing('audit_logs', [
|
$this->assertSoftDeleted('backup_sets', ['id' => $backupSet->id]);
|
||||||
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
'resource_type' => 'backup_set',
|
'resource_type' => 'backup_set',
|
||||||
'resource_id' => (string) $backupSet->id,
|
'resource_id' => (string) $backupSet->id,
|
||||||
'action' => 'backup.deleted',
|
'action' => 'backup.deleted',
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('backup_sets', ['id' => $backupSet->id, 'deleted_at' => null]);
|
$this->assertDatabaseHas('restore_runs', ['id' => $restoreRun->id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('backup set can be force deleted when trashed and unused', function () {
|
test('backup set can be force deleted when trashed and unused', function () {
|
||||||
@ -124,6 +126,45 @@
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('backup set can be restored when archived', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-restore-backup-set',
|
||||||
|
'name' => 'Tenant Restore Backup Set',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Set restore',
|
||||||
|
'status' => 'completed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => null,
|
||||||
|
'policy_identifier' => 'policy-restore',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows',
|
||||||
|
'payload' => ['id' => 'policy-restore'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(ListBackupSets::class)
|
||||||
|
->callTableAction('archive', $backupSet)
|
||||||
|
->set('tableFilters.trashed.value', 1)
|
||||||
|
->callTableAction('restore', $backupSet);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('backup_sets', ['id' => $backupSet->id, 'deleted_at' => null]);
|
||||||
|
$this->assertDatabaseHas('backup_items', ['backup_set_id' => $backupSet->id, 'deleted_at' => null]);
|
||||||
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
|
'resource_type' => 'backup_set',
|
||||||
|
'resource_id' => (string) $backupSet->id,
|
||||||
|
'action' => 'backup.restored',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('restore run can be archived and force deleted', function () {
|
test('restore run can be archived and force deleted', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'tenant_id' => 'tenant-restore-run',
|
'tenant_id' => 'tenant-restore-run',
|
||||||
@ -160,12 +201,85 @@
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('restore run can be restored when archived', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-restore-restore-run',
|
||||||
|
'name' => 'Tenant Restore Restore Run',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Set for restore run restore',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$restoreRun = RestoreRun::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'status' => 'completed',
|
||||||
|
'is_dry_run' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(ListRestoreRuns::class)
|
||||||
|
->callTableAction('archive', $restoreRun)
|
||||||
|
->set('tableFilters.trashed.value', 1)
|
||||||
|
->callTableAction('restore', $restoreRun);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('restore_runs', ['id' => $restoreRun->id, 'deleted_at' => null]);
|
||||||
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
|
'resource_type' => 'restore_run',
|
||||||
|
'resource_id' => (string) $restoreRun->id,
|
||||||
|
'action' => 'restore_run.restored',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('policy can be ignored and restored via row actions', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-policy-row-actions',
|
||||||
|
'name' => 'Tenant Policy Row Actions',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-row-1',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'display_name' => 'Row Action Policy',
|
||||||
|
'platform' => 'windows',
|
||||||
|
'last_synced_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->callTableAction('ignore', $policy);
|
||||||
|
|
||||||
|
$policy->refresh();
|
||||||
|
expect($policy->ignored_at)->not->toBeNull();
|
||||||
|
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->set('tableFilters.visibility.value', 'ignored')
|
||||||
|
->callTableAction('restore', $policy);
|
||||||
|
|
||||||
|
$policy->refresh();
|
||||||
|
expect($policy->ignored_at)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test('policy version can be archived with audit log', function () {
|
test('policy version can be archived with audit log', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'tenant_id' => 'tenant-3',
|
'tenant_id' => 'tenant-3',
|
||||||
'name' => 'Tenant 3',
|
'name' => 'Tenant 3',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'external_id' => 'pol-1',
|
'external_id' => 'pol-1',
|
||||||
@ -201,6 +315,8 @@
|
|||||||
'name' => 'Tenant 3b',
|
'name' => 'Tenant 3b',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
$policy = Policy::create([
|
$policy = Policy::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'external_id' => 'pol-1b',
|
'external_id' => 'pol-1b',
|
||||||
|
|||||||
159
tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php
Normal file
159
tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Intune\PolicySyncService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
class FakeGraphClientForSync implements GraphClientInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, GraphResponse> $responses
|
||||||
|
*/
|
||||||
|
public function __construct(private array $responses = []) {}
|
||||||
|
|
||||||
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return $this->responses[$policyType] ?? new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrganization(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||||
|
{
|
||||||
|
return new GraphResponse(true, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sync revives ignored policies when they exist in Intune', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'),
|
||||||
|
'name' => 'Test Tenant',
|
||||||
|
'metadata' => [],
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create an ignored policy
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-123',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'display_name' => 'Test Policy',
|
||||||
|
'platform' => 'windows',
|
||||||
|
'ignored_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($policy->ignored_at)->not->toBeNull();
|
||||||
|
|
||||||
|
// Mock Graph response with the same policy
|
||||||
|
$responses = [
|
||||||
|
'deviceConfiguration' => new GraphResponse(true, [
|
||||||
|
[
|
||||||
|
'id' => 'policy-123',
|
||||||
|
'displayName' => 'Test Policy (Updated)',
|
||||||
|
'platform' => 'windows',
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
app()->instance(GraphClientInterface::class, new FakeGraphClientForSync($responses));
|
||||||
|
|
||||||
|
// Sync policies
|
||||||
|
app(PolicySyncService::class)->syncPolicies($tenant);
|
||||||
|
|
||||||
|
// Refresh the policy
|
||||||
|
$policy->refresh();
|
||||||
|
|
||||||
|
// Policy should no longer be ignored
|
||||||
|
expect($policy->ignored_at)->toBeNull();
|
||||||
|
expect($policy->display_name)->toBe('Test Policy (Updated)');
|
||||||
|
expect($policy->last_synced_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sync creates new policies even if ignored ones exist with same external_id', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant-2'),
|
||||||
|
'name' => 'Test Tenant 2',
|
||||||
|
'metadata' => [],
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create multiple ignored policies
|
||||||
|
Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-abc',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'display_name' => 'Old Policy ABC',
|
||||||
|
'platform' => 'windows',
|
||||||
|
'ignored_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-def',
|
||||||
|
'policy_type' => 'deviceCompliancePolicy',
|
||||||
|
'display_name' => 'Old Policy DEF',
|
||||||
|
'platform' => 'android',
|
||||||
|
'ignored_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(Policy::active()->count())->toBe(0);
|
||||||
|
expect(Policy::ignored()->count())->toBe(2);
|
||||||
|
|
||||||
|
// Mock Graph response with same policy IDs but potentially different data
|
||||||
|
$responses = [
|
||||||
|
'deviceConfiguration' => new GraphResponse(true, [
|
||||||
|
[
|
||||||
|
'id' => 'policy-abc',
|
||||||
|
'displayName' => 'Restored Policy ABC',
|
||||||
|
'platform' => 'windows',
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'deviceCompliancePolicy' => new GraphResponse(true, [
|
||||||
|
[
|
||||||
|
'id' => 'policy-def',
|
||||||
|
'displayName' => 'Restored Policy DEF',
|
||||||
|
'platform' => 'android',
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
app()->instance(GraphClientInterface::class, new FakeGraphClientForSync($responses));
|
||||||
|
|
||||||
|
// Sync policies
|
||||||
|
app(PolicySyncService::class)->syncPolicies($tenant);
|
||||||
|
|
||||||
|
// All policies should now be active
|
||||||
|
expect(Policy::active()->count())->toBe(2);
|
||||||
|
expect(Policy::ignored()->count())->toBe(0);
|
||||||
|
|
||||||
|
$policyAbc = Policy::where('external_id', 'policy-abc')->first();
|
||||||
|
expect($policyAbc->display_name)->toBe('Restored Policy ABC');
|
||||||
|
expect($policyAbc->ignored_at)->toBeNull();
|
||||||
|
|
||||||
|
$policyDef = Policy::where('external_id', 'policy-def')->first();
|
||||||
|
expect($policyDef->display_name)->toBe('Restored Policy DEF');
|
||||||
|
expect($policyDef->ignored_at)->toBeNull();
|
||||||
|
});
|
||||||
@ -6,7 +6,11 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
|
putenv('INTUNE_TENANT_ID');
|
||||||
|
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
||||||
|
|
||||||
$this->tenant = Tenant::factory()->create();
|
$this->tenant = Tenant::factory()->create();
|
||||||
|
$this->tenant->makeCurrent();
|
||||||
$this->policy = Policy::factory()->create([
|
$this->policy = Policy::factory()->create([
|
||||||
'tenant_id' => $this->tenant->id,
|
'tenant_id' => $this->tenant->id,
|
||||||
]);
|
]);
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||
@ -17,6 +17,11 @@
|
|||||||
->use(RefreshDatabase::class)
|
->use(RefreshDatabase::class)
|
||||||
->in('Feature');
|
->in('Feature');
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
putenv('INTUNE_TENANT_ID');
|
||||||
|
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Expectations
|
| Expectations
|
||||||
|
|||||||
23
tests/Unit/BulkActionPermissionTest.php
Normal file
23
tests/Unit/BulkActionPermissionTest.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyResource;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('policies bulk actions are available for authenticated users', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$policies = Policy::factory()->count(2)->create(['tenant_id' => $tenant->id]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(PolicyResource\Pages\ListPolicies::class)
|
||||||
|
->callTableBulkAction('bulk_sync', $policies)
|
||||||
|
->assertHasNoTableBulkActionErrors();
|
||||||
|
});
|
||||||
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 archives sets even when 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(1)
|
||||||
|
->and($run->failed)->toBe(0)
|
||||||
|
->and($run->skipped)->toBe(0);
|
||||||
|
|
||||||
|
expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue();
|
||||||
|
expect(RestoreRun::query()->where('backup_set_id', $set->id)->exists())->toBeTrue();
|
||||||
|
});
|
||||||
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');
|
||||||
|
});
|
||||||
24
tests/Unit/BulkOperationAbortMethodTest.php
Normal file
24
tests/Unit/BulkOperationAbortMethodTest.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('can abort a bulk operation run', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$run = BulkOperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'status' => 'running',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(BulkOperationService::class)->abort($run, 'threshold exceeded');
|
||||||
|
|
||||||
|
expect($run->refresh()->status)->toBe('aborted');
|
||||||
|
});
|
||||||
33
tests/Unit/BulkOperationRunProgressTest.php
Normal file
33
tests/Unit/BulkOperationRunProgressTest.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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 operation service updates progress counters', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'delete', [1, 2, 3], 3);
|
||||||
|
|
||||||
|
$service->start($run);
|
||||||
|
|
||||||
|
$service->recordSuccess($run);
|
||||||
|
$service->recordSkipped($run);
|
||||||
|
$service->recordFailure($run, '3', 'Test failure');
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
|
||||||
|
expect($run->status)->toBe('running')
|
||||||
|
->and($run->processed_items)->toBe(3)
|
||||||
|
->and($run->succeeded)->toBe(1)
|
||||||
|
->and($run->skipped)->toBe(1)
|
||||||
|
->and($run->failed)->toBe(1)
|
||||||
|
->and($run->failures)->toBeArray()
|
||||||
|
->and($run->failures)->toHaveCount(1);
|
||||||
|
});
|
||||||
61
tests/Unit/BulkPolicyDeleteJobTest.php
Normal file
61
tests/Unit/BulkPolicyDeleteJobTest.php
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\BulkPolicyDeleteJob;
|
||||||
|
use App\Models\Policy;
|
||||||
|
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 processes bulk delete successfully', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policies = Policy::factory()->count(3)->create(['tenant_id' => $tenant->id]);
|
||||||
|
$policyIds = $policies->pluck('id')->toArray();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 3);
|
||||||
|
|
||||||
|
$job = new BulkPolicyDeleteJob($run->id);
|
||||||
|
$job->handle($service);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed')
|
||||||
|
->and($run->processed_items)->toBe(3)
|
||||||
|
->and($run->succeeded)->toBe(3)
|
||||||
|
->and($run->failed)->toBe(0);
|
||||||
|
|
||||||
|
$policies->each(function ($policy) {
|
||||||
|
expect($policy->refresh()->ignored_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('job handles partial failures gracefully', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policies = Policy::factory()->count(2)->create(['tenant_id' => $tenant->id]);
|
||||||
|
$policyIds = $policies->pluck('id')->toArray();
|
||||||
|
|
||||||
|
// Add a non-existent ID
|
||||||
|
$policyIds[] = 99999;
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 3);
|
||||||
|
|
||||||
|
$job = new BulkPolicyDeleteJob($run->id);
|
||||||
|
$job->handle($service);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed_with_errors')
|
||||||
|
->and($run->processed_items)->toBe(3)
|
||||||
|
->and($run->succeeded)->toBe(2)
|
||||||
|
->and($run->failed)->toBe(1);
|
||||||
|
|
||||||
|
expect($run->failures[0]['item_id'])->toBe('99999')
|
||||||
|
->and($run->failures[0]['reason'])->toContain('not found');
|
||||||
|
});
|
||||||
88
tests/Unit/BulkPolicyExportJobTest.php
Normal file
88
tests/Unit/BulkPolicyExportJobTest.php
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\BulkPolicyExportJob;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
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 processes bulk export successfully', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policies = Policy::factory()->count(3)->create(['tenant_id' => $tenant->id]);
|
||||||
|
|
||||||
|
// Create versions for policies so they can be backed up
|
||||||
|
$policies->each(function ($policy) use ($tenant) {
|
||||||
|
PolicyVersion::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'version_number' => 1,
|
||||||
|
'snapshot' => ['name' => $policy->display_name],
|
||||||
|
'captured_at' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$policyIds = $policies->pluck('id')->toArray();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'export', $policyIds, 3);
|
||||||
|
|
||||||
|
$job = new BulkPolicyExportJob($run->id, 'My Bulk Backup');
|
||||||
|
$job->handle($service);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed')
|
||||||
|
->and($run->processed_items)->toBe(3)
|
||||||
|
->and($run->succeeded)->toBe(3);
|
||||||
|
|
||||||
|
// Verify BackupSet created
|
||||||
|
$backupSet = BackupSet::where('name', 'My Bulk Backup')->first();
|
||||||
|
expect($backupSet)->not->toBeNull()
|
||||||
|
->and($backupSet->tenant_id)->toBe($tenant->id);
|
||||||
|
|
||||||
|
// Verify BackupItems created
|
||||||
|
expect(BackupItem::where('backup_set_id', $backupSet->id)->count())->toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('job handles policies without versions gracefully', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policyWithVersion = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
PolicyVersion::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policyWithVersion->id,
|
||||||
|
'policy_type' => $policyWithVersion->policy_type,
|
||||||
|
'version_number' => 1,
|
||||||
|
'snapshot' => ['name' => 'ok'],
|
||||||
|
'captured_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policyNoVersion = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
|
||||||
|
$policyIds = [$policyWithVersion->id, $policyNoVersion->id];
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'export', $policyIds, 2);
|
||||||
|
|
||||||
|
$job = new BulkPolicyExportJob($run->id, 'Partial Backup');
|
||||||
|
$job->handle($service);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed_with_errors')
|
||||||
|
->and($run->processed_items)->toBe(2)
|
||||||
|
->and($run->succeeded)->toBe(1)
|
||||||
|
->and($run->failed)->toBe(1);
|
||||||
|
|
||||||
|
expect($run->failures[0]['item_id'])->toBe((string) $policyNoVersion->id)
|
||||||
|
->and($run->failures[0]['reason'])->toContain('No versions available');
|
||||||
|
});
|
||||||
69
tests/Unit/BulkPolicyVersionForceDeleteJobTest.php
Normal file
69
tests/Unit/BulkPolicyVersionForceDeleteJobTest.php
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
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 force deletes archived versions and skips active versions', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
|
||||||
|
$archived = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'captured_at' => now()->subDays(120),
|
||||||
|
]);
|
||||||
|
$archived->delete();
|
||||||
|
|
||||||
|
$active = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 2,
|
||||||
|
'captured_at' => now()->subDays(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', [$archived->id, $active->id], 2);
|
||||||
|
|
||||||
|
(new BulkPolicyVersionForceDeleteJob($run->id))->handle($service);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
|
||||||
|
expect($run->status)->toBe('completed')
|
||||||
|
->and($run->processed_items)->toBe(2)
|
||||||
|
->and($run->succeeded)->toBe(1)
|
||||||
|
->and($run->skipped)->toBe(1)
|
||||||
|
->and($run->failed)->toBe(0);
|
||||||
|
|
||||||
|
expect(PolicyVersion::withTrashed()->whereKey($archived->id)->exists())->toBeFalse();
|
||||||
|
expect($active->refresh()->trashed())->toBeFalse();
|
||||||
|
|
||||||
|
$reasons = collect($run->failures)->pluck('reason')->all();
|
||||||
|
expect($reasons)->toContain('Not archived');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('job aborts when the failure threshold is exceeded', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', [999999], 1);
|
||||||
|
|
||||||
|
(new BulkPolicyVersionForceDeleteJob($run->id))->handle($service);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
|
||||||
|
expect($run->status)->toBe('aborted')
|
||||||
|
->and($run->failed)->toBe(1)
|
||||||
|
->and($run->processed_items)->toBe(1);
|
||||||
|
});
|
||||||
120
tests/Unit/BulkPolicyVersionPruneJobTest.php
Normal file
120
tests/Unit/BulkPolicyVersionPruneJobTest.php
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\BulkPolicyVersionPruneJob;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
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 prunes eligible versions and skips ineligible with reasons', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policyA = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
$eligible = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policyA->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'captured_at' => now()->subDays(120),
|
||||||
|
]);
|
||||||
|
$current = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policyA->id,
|
||||||
|
'version_number' => 2,
|
||||||
|
'captured_at' => now()->subDays(120),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policyB = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
$tooRecent = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policyB->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'captured_at' => now()->subDays(10),
|
||||||
|
]);
|
||||||
|
PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policyB->id,
|
||||||
|
'version_number' => 2,
|
||||||
|
'captured_at' => now()->subDays(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun(
|
||||||
|
$tenant,
|
||||||
|
$user,
|
||||||
|
'policy_version',
|
||||||
|
'prune',
|
||||||
|
[$eligible->id, $current->id, $tooRecent->id],
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
(new BulkPolicyVersionPruneJob($run->id, 90))->handle($service);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed')
|
||||||
|
->and($run->processed_items)->toBe(3)
|
||||||
|
->and($run->succeeded)->toBe(1)
|
||||||
|
->and($run->skipped)->toBe(2)
|
||||||
|
->and($run->failed)->toBe(0);
|
||||||
|
|
||||||
|
expect($eligible->refresh()->trashed())->toBeTrue();
|
||||||
|
expect($current->refresh()->trashed())->toBeFalse();
|
||||||
|
expect($tooRecent->refresh()->trashed())->toBeFalse();
|
||||||
|
|
||||||
|
expect($run->failures)->toBeArray();
|
||||||
|
expect(collect($run->failures)->pluck('type')->all())->toContain('skipped');
|
||||||
|
|
||||||
|
$reasons = collect($run->failures)->pluck('reason')->all();
|
||||||
|
expect($reasons)->toContain('Current version')
|
||||||
|
->and($reasons)->toContain('Too recent');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('job records failure when version is missing', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy_version', 'prune', [999999], 1);
|
||||||
|
|
||||||
|
(new BulkPolicyVersionPruneJob($run->id, 90))->handle($service);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('aborted')
|
||||||
|
->and($run->failed)->toBe(1)
|
||||||
|
->and($run->processed_items)->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('job skips already archived versions instead of treating them as missing', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
$archived = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'captured_at' => now()->subDays(120),
|
||||||
|
]);
|
||||||
|
$archived->delete();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy_version', 'prune', [$archived->id], 1);
|
||||||
|
|
||||||
|
(new BulkPolicyVersionPruneJob($run->id, 90))->handle($service);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
|
||||||
|
expect($run->status)->toBe('completed')
|
||||||
|
->and($run->processed_items)->toBe(1)
|
||||||
|
->and($run->succeeded)->toBe(0)
|
||||||
|
->and($run->skipped)->toBe(1)
|
||||||
|
->and($run->failed)->toBe(0);
|
||||||
|
|
||||||
|
$reasons = collect($run->failures)->pluck('reason')->all();
|
||||||
|
expect($reasons)->toContain('Already archived');
|
||||||
|
});
|
||||||
88
tests/Unit/BulkPolicyVersionRestoreJobTest.php
Normal file
88
tests/Unit/BulkPolicyVersionRestoreJobTest.php
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\BulkPolicyVersionRestoreJob;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
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 policy version restore restores archived versions', function () {
|
||||||
|
$tenant = Tenant::factory()->create(['is_current' => true]);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
$version = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'captured_at' => now()->subDays(120),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version->delete();
|
||||||
|
expect($version->trashed())->toBeTrue();
|
||||||
|
|
||||||
|
$run = BulkOperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'resource' => 'policy_version',
|
||||||
|
'action' => 'restore',
|
||||||
|
'status' => 'pending',
|
||||||
|
'total_items' => 1,
|
||||||
|
'item_ids' => [$version->id],
|
||||||
|
'failures' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
(new BulkPolicyVersionRestoreJob($run->id))->handle(app(BulkOperationService::class));
|
||||||
|
|
||||||
|
$version->refresh();
|
||||||
|
expect($version->trashed())->toBeFalse();
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed')
|
||||||
|
->and($run->succeeded)->toBe(1)
|
||||||
|
->and($run->skipped)->toBe(0)
|
||||||
|
->and($run->failed)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk policy version restore skips active versions', function () {
|
||||||
|
$tenant = Tenant::factory()->create(['is_current' => true]);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
$version = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'captured_at' => now()->subDays(120),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = BulkOperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'resource' => 'policy_version',
|
||||||
|
'action' => 'restore',
|
||||||
|
'status' => 'pending',
|
||||||
|
'total_items' => 1,
|
||||||
|
'item_ids' => [$version->id],
|
||||||
|
'failures' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
(new BulkPolicyVersionRestoreJob($run->id))->handle(app(BulkOperationService::class));
|
||||||
|
|
||||||
|
$version->refresh();
|
||||||
|
expect($version->trashed())->toBeFalse();
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed')
|
||||||
|
->and($run->succeeded)->toBe(0)
|
||||||
|
->and($run->skipped)->toBe(1)
|
||||||
|
->and($run->failed)->toBe(0);
|
||||||
|
|
||||||
|
expect(collect($run->failures)->pluck('reason')->all())->toContain('Not archived');
|
||||||
|
});
|
||||||
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');
|
||||||
|
});
|
||||||
47
tests/Unit/CircuitBreakerTest.php
Normal file
47
tests/Unit/CircuitBreakerTest.php
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\BulkPolicyDeleteJob;
|
||||||
|
use App\Jobs\BulkPolicyExportJob;
|
||||||
|
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 delete aborts when more than half of items fail', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'delete', [100001, 100002, 100003, 100004, 100005, 100006, 100007, 100008, 100009, 100010], 10);
|
||||||
|
|
||||||
|
(new BulkPolicyDeleteJob($run->id))->handle($service);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('aborted')
|
||||||
|
->and($run->failed)->toBe(6)
|
||||||
|
->and($run->processed_items)->toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk export aborts when more than half of items fail', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$service = app(BulkOperationService::class);
|
||||||
|
$run = $service->createRun($tenant, $user, 'policy', 'export', [200001, 200002, 200003, 200004, 200005, 200006, 200007, 200008, 200009, 200010], 10);
|
||||||
|
|
||||||
|
(new BulkPolicyExportJob($run->id, 'Circuit Breaker Backup'))->handle($service);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('aborted')
|
||||||
|
->and($run->failed)->toBe(6)
|
||||||
|
->and($run->processed_items)->toBe(6);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('backup_sets', [
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Circuit Breaker Backup',
|
||||||
|
'status' => 'failed',
|
||||||
|
]);
|
||||||
|
});
|
||||||
84
tests/Unit/PolicyVersionEligibilityTest.php
Normal file
84
tests/Unit/PolicyVersionEligibilityTest.php
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('pruneEligible returns only old non-current versions', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
|
||||||
|
$oldNonCurrent = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'captured_at' => now()->subDays(120),
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 2,
|
||||||
|
'captured_at' => now()->subDays(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$eligibleIds = PolicyVersion::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->pruneEligible(90)
|
||||||
|
->pluck('id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($eligibleIds)->toBe([$oldNonCurrent->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pruneEligible excludes current even when old', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'captured_at' => now()->subDays(120),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$eligibleCount = PolicyVersion::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->pruneEligible(90)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
expect($eligibleCount)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pruneEligible excludes archived versions', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
|
||||||
|
$archived = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'captured_at' => now()->subDays(120),
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 2,
|
||||||
|
'captured_at' => now()->subDays(120),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$archived->delete();
|
||||||
|
|
||||||
|
$eligibleIds = PolicyVersion::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->pruneEligible(90)
|
||||||
|
->pluck('id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($eligibleIds)->not->toContain($archived->id);
|
||||||
|
});
|
||||||
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