diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 62118e0..1a9df86 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -3,6 +3,8 @@ # TenantAtlas Development Guidelines Auto-generated from all feature plans. Last updated: 2025-12-22 ## 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) @@ -22,6 +24,7 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## 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 diff --git a/README.md b/README.md index 85438b8..ddc34ef 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,17 @@ ## TenantPilot setup - Ensure queue workers are running for jobs (e.g., policy sync) after deploy. - Keep secrets/env in Dokploy, never in code. +## Bulk operations (Feature 005) + +- Bulk actions are available in Filament resource tables (Policies, Policy Versions, Backup Sets, Restore Runs). +- Destructive operations require type-to-confirm at higher thresholds (e.g. `DELETE`). +- Long-running bulk ops are queued; the bottom-right progress widget polls for active runs. + +### Configuration + +- `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`): job refresh/progress chunk size. +- `TENANTPILOT_BULK_POLL_INTERVAL_SECONDS` (default `3`): Livewire polling interval for the progress widget (clamped to 1–10s). + ## Intune RBAC Onboarding Wizard - Entry point: Tenant detail in Filament (`Setup Intune RBAC` in the ⋯ ActionGroup). Visible only for active tenants with `app_client_id`. diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index e133010..b0d5664 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -4,20 +4,29 @@ use App\Filament\Resources\BackupSetResource\Pages; use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager; +use App\Jobs\BulkBackupSetDeleteJob; +use App\Jobs\BulkBackupSetForceDeleteJob; +use App\Jobs\BulkBackupSetRestoreJob; use App\Models\BackupSet; use App\Models\Tenant; +use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Intune\BackupService; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; +use Filament\Actions\BulkAction; +use Filament\Actions\BulkActionGroup; use Filament\Forms; use Filament\Infolists; use Filament\Notifications\Notification; use Filament\Resources\Resource; use Filament\Schemas\Schema; use Filament\Tables; +use Filament\Tables\Contracts\HasTable; +use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Collection; use UnitEnum; class BackupSetResource extends Resource @@ -51,13 +60,43 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('created_at')->dateTime()->since(), ]) ->filters([ - Tables\Filters\TrashedFilter::make(), + Tables\Filters\TrashedFilter::make() + ->label('Archived') + ->placeholder('Active') + ->trueLabel('All') + ->falseLabel('Archived'), ]) ->actions([ Actions\ViewAction::make() ->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record])) ->openUrlInNewTab(false), 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') ->label('Archive') ->color('danger') @@ -65,16 +104,6 @@ public static function table(Table $table): Table ->requiresConfirmation() ->visible(fn (BackupSet $record) => ! $record->trashed()) ->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(); if ($record->tenant) { @@ -131,7 +160,164 @@ public static function table(Table $table): Table }), ])->icon('heroicon-o-ellipsis-vertical'), ]) - ->bulkActions([]); + ->bulkActions([ + BulkActionGroup::make([ + BulkAction::make('bulk_delete') + ->label('Archive Backup Sets') + ->icon('heroicon-o-archive-box-x-mark') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return $isOnlyTrashed; + }) + ->modalDescription('This archives backup sets (soft delete). 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 diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index 4ea521f..b2d2ed5 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -4,21 +4,33 @@ use App\Filament\Resources\PolicyResource\Pages; 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\Tenant; +use App\Services\BulkOperationService; use App\Services\Intune\PolicyNormalizer; use BackedEnum; 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\ViewEntry; +use Filament\Notifications\Notification; use Filament\Resources\Resource; use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Schema; use Filament\Tables; +use Filament\Tables\Contracts\HasTable; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use UnitEnum; class PolicyResource extends Resource @@ -258,6 +270,30 @@ public static function table(Table $table): Table ->toggleable(isToggledHiddenByDefault: true), ]) ->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') ->options(function () { return collect(config('tenantpilot.supported_policy_types', [])) @@ -288,12 +324,248 @@ public static function table(Table $table): Table $query->whereIn('policy_type', $types); }), 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\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()), + ]) + ->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([]); + ->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 diff --git a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php index 601b9c0..e3743d2 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php +++ b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php @@ -34,12 +34,14 @@ protected function getHeaderActions(): array ->title('Policy sync completed') ->body(count($synced).' policies synced') ->success() + ->sendToDatabase(auth()->user()) ->send(); } catch (\Throwable $e) { Notification::make() ->title('Policy sync failed') ->body($e->getMessage()) ->danger() + ->sendToDatabase(auth()->user()) ->send(); } }), diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index fb2a570..89cce02 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -3,12 +3,20 @@ namespace App\Filament\Resources; 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\Tenant; +use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\VersionDiff; use BackedEnum; use Filament\Actions; +use Filament\Actions\BulkAction; +use Filament\Actions\BulkActionGroup; +use Filament\Forms; use Filament\Infolists; use Filament\Notifications\Notification; use Filament\Resources\Resource; @@ -16,7 +24,11 @@ use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Schema; use Filament\Tables; +use Filament\Tables\Contracts\HasTable; +use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; use UnitEnum; class PolicyVersionResource extends Resource @@ -112,7 +124,11 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(), ]) ->filters([ - Tables\Filters\TrashedFilter::make(), + TrashedFilter::make() + ->label('Archived') + ->placeholder('Active') + ->trueLabel('All') + ->falseLabel('Archived'), ]) ->actions([ Actions\ViewAction::make() @@ -169,9 +185,205 @@ public static function table(Table $table): Table ->success() ->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'), ]) - ->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 diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 95ab893..12c136b 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -3,16 +3,22 @@ namespace App\Filament\Resources; use App\Filament\Resources\RestoreRunResource\Pages; +use App\Jobs\BulkRestoreRunDeleteJob; +use App\Jobs\BulkRestoreRunForceDeleteJob; +use App\Jobs\BulkRestoreRunRestoreJob; use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\RestoreRun; use App\Models\Tenant; +use App\Services\BulkOperationService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GroupResolver; use App\Services\Intune\RestoreService; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; +use Filament\Actions\BulkAction; +use Filament\Actions\BulkActionGroup; use Filament\Forms; use Filament\Infolists; use Filament\Notifications\Notification; @@ -22,7 +28,10 @@ use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; use Filament\Tables; +use Filament\Tables\Contracts\HasTable; +use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Str; use UnitEnum; @@ -164,11 +173,40 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('requested_by')->label('Requested by'), ]) ->filters([ - Tables\Filters\TrashedFilter::make(), + TrashedFilter::make() + ->label('Archived') + ->placeholder('Active') + ->trueLabel('All') + ->falseLabel('Archived'), ]) ->actions([ Actions\ViewAction::make(), 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') ->label('Archive') ->color('danger') @@ -176,6 +214,16 @@ public static function table(Table $table): Table ->requiresConfirmation() ->visible(fn (RestoreRun $record) => ! $record->trashed()) ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + if (! $record->isDeletable()) { + Notification::make() + ->title('Restore run cannot be archived') + ->body("Not deletable (status: {$record->status})") + ->warning() + ->send(); + + return; + } + $record->delete(); if ($record->tenant) { @@ -221,7 +269,158 @@ public static function table(Table $table): Table }), ])->icon('heroicon-o-ellipsis-vertical'), ]) - ->bulkActions([]); + ->bulkActions([ + BulkActionGroup::make([ + BulkAction::make('bulk_delete') + ->label('Archive Restore Runs') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return $isOnlyTrashed; + }) + ->modalDescription('This archives restore runs (soft delete). Already archived runs will be skipped.') + ->form(function (Collection $records) { + if ($records->count() >= 20) { + return [ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]; + } + + return []; + }) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'restore_run', 'delete', $ids, $count); + + if ($count >= 20) { + Notification::make() + ->title('Bulk delete started') + ->body("Deleting {$count} restore runs in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkRestoreRunDeleteJob::dispatch($run->id); + } else { + BulkRestoreRunDeleteJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + + BulkAction::make('bulk_restore') + ->label('Restore Restore Runs') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Restore {$records->count()} restore runs?") + ->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.') + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'restore_run', 'restore', $ids, $count); + + if ($count >= 20) { + Notification::make() + ->title('Bulk restore started') + ->body("Restoring {$count} restore runs in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkRestoreRunRestoreJob::dispatch($run->id); + } else { + BulkRestoreRunRestoreJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + + BulkAction::make('bulk_force_delete') + ->label('Force Delete Restore Runs') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} restore runs?") + ->modalDescription('This is permanent. Only archived restore runs will be permanently deleted; active runs will be skipped.') + ->form([ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'restore_run', 'force_delete', $ids, $count); + + if ($count >= 20) { + Notification::make() + ->title('Bulk force delete started') + ->body("Force deleting {$count} restore runs in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkRestoreRunForceDeleteJob::dispatch($run->id); + } else { + BulkRestoreRunForceDeleteJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + ]), + ]); } public static function infolist(Schema $schema): Schema diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 40a635c..7f7baf3 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -97,10 +97,10 @@ public static function table(Table $table): Table ]) ->filters([ Tables\Filters\TrashedFilter::make() - ->label('Archive filter') - ->placeholder('Active only') - ->trueLabel('Active + archived') - ->falseLabel('Archived only') + ->label('Archived') + ->placeholder('Active') + ->trueLabel('All') + ->falseLabel('Archived') ->default(true), Tables\Filters\SelectFilter::make('app_status') ->options([ diff --git a/app/Jobs/BulkBackupSetDeleteJob.php b/app/Jobs/BulkBackupSetDeleteJob.php new file mode 100644 index 0000000..fce556e --- /dev/null +++ b/app/Jobs/BulkBackupSetDeleteJob.php @@ -0,0 +1,150 @@ +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(); + } +} diff --git a/app/Jobs/BulkBackupSetForceDeleteJob.php b/app/Jobs/BulkBackupSetForceDeleteJob.php new file mode 100644 index 0000000..b53a1e4 --- /dev/null +++ b/app/Jobs/BulkBackupSetForceDeleteJob.php @@ -0,0 +1,160 @@ +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(); + } +} diff --git a/app/Jobs/BulkBackupSetRestoreJob.php b/app/Jobs/BulkBackupSetRestoreJob.php new file mode 100644 index 0000000..0d39cea --- /dev/null +++ b/app/Jobs/BulkBackupSetRestoreJob.php @@ -0,0 +1,152 @@ +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(); + } +} diff --git a/app/Jobs/BulkPolicyDeleteJob.php b/app/Jobs/BulkPolicyDeleteJob.php new file mode 100644 index 0000000..20fb6b1 --- /dev/null +++ b/app/Jobs/BulkPolicyDeleteJob.php @@ -0,0 +1,185 @@ +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; + } + + } +} diff --git a/app/Jobs/BulkPolicyExportJob.php b/app/Jobs/BulkPolicyExportJob.php new file mode 100644 index 0000000..76b51c3 --- /dev/null +++ b/app/Jobs/BulkPolicyExportJob.php @@ -0,0 +1,219 @@ +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; + } + } +} diff --git a/app/Jobs/BulkPolicySyncJob.php b/app/Jobs/BulkPolicySyncJob.php new file mode 100644 index 0000000..0ec2f4d --- /dev/null +++ b/app/Jobs/BulkPolicySyncJob.php @@ -0,0 +1,147 @@ +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; + } + } +} diff --git a/app/Jobs/BulkPolicyUnignoreJob.php b/app/Jobs/BulkPolicyUnignoreJob.php new file mode 100644 index 0000000..edbf4eb --- /dev/null +++ b/app/Jobs/BulkPolicyUnignoreJob.php @@ -0,0 +1,116 @@ +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; + } + } +} diff --git a/app/Jobs/BulkPolicyVersionForceDeleteJob.php b/app/Jobs/BulkPolicyVersionForceDeleteJob.php new file mode 100644 index 0000000..2275408 --- /dev/null +++ b/app/Jobs/BulkPolicyVersionForceDeleteJob.php @@ -0,0 +1,148 @@ +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(); + } + } +} diff --git a/app/Jobs/BulkPolicyVersionPruneJob.php b/app/Jobs/BulkPolicyVersionPruneJob.php new file mode 100644 index 0000000..d8009cf --- /dev/null +++ b/app/Jobs/BulkPolicyVersionPruneJob.php @@ -0,0 +1,178 @@ +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(); + } + } +} diff --git a/app/Jobs/BulkPolicyVersionRestoreJob.php b/app/Jobs/BulkPolicyVersionRestoreJob.php new file mode 100644 index 0000000..6e6c17d --- /dev/null +++ b/app/Jobs/BulkPolicyVersionRestoreJob.php @@ -0,0 +1,148 @@ +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(); + } + } +} diff --git a/app/Jobs/BulkRestoreRunDeleteJob.php b/app/Jobs/BulkRestoreRunDeleteJob.php new file mode 100644 index 0000000..4864e8e --- /dev/null +++ b/app/Jobs/BulkRestoreRunDeleteJob.php @@ -0,0 +1,177 @@ +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; + } + } +} diff --git a/app/Jobs/BulkRestoreRunForceDeleteJob.php b/app/Jobs/BulkRestoreRunForceDeleteJob.php new file mode 100644 index 0000000..5f8dbc5 --- /dev/null +++ b/app/Jobs/BulkRestoreRunForceDeleteJob.php @@ -0,0 +1,150 @@ +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(); + } +} diff --git a/app/Jobs/BulkRestoreRunRestoreJob.php b/app/Jobs/BulkRestoreRunRestoreJob.php new file mode 100644 index 0000000..2b8efe4 --- /dev/null +++ b/app/Jobs/BulkRestoreRunRestoreJob.php @@ -0,0 +1,150 @@ +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(); + } +} diff --git a/app/Livewire/BulkOperationProgress.php b/app/Livewire/BulkOperationProgress.php new file mode 100644 index 0000000..975a619 --- /dev/null +++ b/app/Livewire/BulkOperationProgress.php @@ -0,0 +1,50 @@ +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'); + } +} diff --git a/app/Models/BulkOperationRun.php b/app/Models/BulkOperationRun.php new file mode 100644 index 0000000..9342a26 --- /dev/null +++ b/app/Models/BulkOperationRun.php @@ -0,0 +1,53 @@ + '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); + } +} diff --git a/app/Models/Policy.php b/app/Models/Policy.php index 61498d9..da28fd0 100644 --- a/app/Models/Policy.php +++ b/app/Models/Policy.php @@ -18,6 +18,7 @@ class Policy extends Model protected $casts = [ 'metadata' => 'array', 'last_synced_at' => 'datetime', + 'ignored_at' => 'datetime', ]; public function tenant(): BelongsTo @@ -34,4 +35,24 @@ public function backupItems(): HasMany { 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]); + } } diff --git a/app/Models/PolicyVersion.php b/app/Models/PolicyVersion.php index ae2555e..0994797 100644 --- a/app/Models/PolicyVersion.php +++ b/app/Models/PolicyVersion.php @@ -42,4 +42,14 @@ public function policy(): BelongsTo { 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)' + ); + } } diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index b729987..28945c4 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -32,7 +32,20 @@ public function tenant(): 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 diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 7c5299e..9827abc 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -10,6 +10,7 @@ use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; +use Filament\View\PanelsRenderHook; use Filament\Widgets\AccountWidget; use Filament\Widgets\FilamentInfoWidget; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; @@ -31,6 +32,10 @@ public function panel(Panel $panel): Panel ->colors([ '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') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->pages([ @@ -41,6 +46,7 @@ public function panel(Panel $panel): Panel AccountWidget::class, FilamentInfoWidget::class, ]) + ->databaseNotifications() ->middleware([ EncryptCookies::class, AddQueuedCookiesToResponse::class, diff --git a/app/Services/BulkOperationService.php b/app/Services/BulkOperationService.php new file mode 100644 index 0000000..76b3af7 --- /dev/null +++ b/app/Services/BulkOperationService.php @@ -0,0 +1,189 @@ + $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 + ); + } +} diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index 399be6b..6ed859c 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -8,6 +8,7 @@ use App\Services\Graph\GraphErrorMapper; use App\Services\Graph\GraphLogger; use Illuminate\Support\Arr; +use RuntimeException; use Throwable; class PolicySyncService @@ -97,6 +98,7 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr 'display_name' => $displayName, 'platform' => $policyPlatform, 'last_synced_at' => now(), + 'ignored_at' => null, '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; } + + /** + * 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(); + } } diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 6f64d4d..6de7643 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -118,4 +118,9 @@ 'features' => [ 'conditional_access' => true, ], + + 'bulk_operations' => [ + 'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10), + 'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3), + ], ]; diff --git a/database/factories/BulkOperationRunFactory.php b/database/factories/BulkOperationRunFactory.php new file mode 100644 index 0000000..55503f0 --- /dev/null +++ b/database/factories/BulkOperationRunFactory.php @@ -0,0 +1,34 @@ + + */ +class BulkOperationRunFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => \App\Models\Tenant::factory(), + 'user_id' => \App\Models\User::factory(), + 'resource' => 'policy', + 'action' => 'delete', + 'status' => 'pending', + 'total_items' => 10, + 'processed_items' => 0, + 'succeeded' => 0, + 'failed' => 0, + 'skipped' => 0, + 'item_ids' => range(1, 10), + 'failures' => [], + ]; + } +} diff --git a/database/factories/PolicyFactory.php b/database/factories/PolicyFactory.php index 5b5bda8..7ec96b2 100644 --- a/database/factories/PolicyFactory.php +++ b/database/factories/PolicyFactory.php @@ -20,9 +20,9 @@ public function definition(): array return [ 'tenant_id' => Tenant::factory(), 'external_id' => fake()->uuid(), + 'display_name' => fake()->words(3, true), 'policy_type' => 'settingsCatalogPolicy', 'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10']), - 'display_name' => fake()->words(3, true), 'last_synced_at' => now(), 'metadata' => [], ]; diff --git a/database/factories/PolicyVersionFactory.php b/database/factories/PolicyVersionFactory.php index fe87d32..04c2c1d 100644 --- a/database/factories/PolicyVersionFactory.php +++ b/database/factories/PolicyVersionFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; +use App\Models\Policy; +use App\Models\Tenant; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -17,14 +19,15 @@ class PolicyVersionFactory extends Factory public function definition(): array { return [ - 'tenant_id' => \App\Models\Tenant::factory(), - 'policy_id' => \App\Models\Policy::factory(), + 'tenant_id' => Tenant::factory(), + 'policy_id' => Policy::factory(), 'version_number' => 1, - 'policy_type' => 'deviceManagementConfigurationPolicy', - 'platform' => 'windows10', - 'snapshot' => ['test' => 'data'], - 'metadata' => [], + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10']), + 'created_by' => fake()->safeEmail(), 'captured_at' => now(), + 'snapshot' => ['example' => true], + 'metadata' => [], ]; } } diff --git a/database/factories/TenantFactory.php b/database/factories/TenantFactory.php index 3a3e4ea..0938ebe 100644 --- a/database/factories/TenantFactory.php +++ b/database/factories/TenantFactory.php @@ -18,9 +18,15 @@ public function definition(): array { return [ 'name' => fake()->company(), + 'external_id' => fake()->uuid(), 'tenant_id' => fake()->uuid(), 'app_client_id' => fake()->uuid(), 'app_client_secret' => null, // Skip encryption in tests + 'app_certificate_thumbprint' => null, + 'app_status' => 'ok', + 'app_notes' => null, + 'status' => 'active', + 'is_current' => false, 'metadata' => [], ]; } diff --git a/database/migrations/2025_12_23_215901_create_bulk_operation_runs_table.php b/database/migrations/2025_12_23_215901_create_bulk_operation_runs_table.php new file mode 100644 index 0000000..18e0547 --- /dev/null +++ b/database/migrations/2025_12_23_215901_create_bulk_operation_runs_table.php @@ -0,0 +1,50 @@ +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'); + } +}; diff --git a/database/migrations/2025_12_23_215905_add_ignored_at_to_policies_table.php b/database/migrations/2025_12_23_215905_add_ignored_at_to_policies_table.php new file mode 100644 index 0000000..e8d8443 --- /dev/null +++ b/database/migrations/2025_12_23_215905_add_ignored_at_to_policies_table.php @@ -0,0 +1,28 @@ +timestamp('ignored_at')->nullable()->after('updated_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('policies', function (Blueprint $table) { + $table->dropColumn('ignored_at'); + }); + } +}; diff --git a/database/migrations/2025_12_24_002001_create_notifications_table.php b/database/migrations/2025_12_24_002001_create_notifications_table.php new file mode 100644 index 0000000..f09da33 --- /dev/null +++ b/database/migrations/2025_12_24_002001_create_notifications_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/database/migrations/2025_12_24_005055_increase_bulk_operation_runs_status_length.php b/database/migrations/2025_12_24_005055_increase_bulk_operation_runs_status_length.php new file mode 100644 index 0000000..1c558ec --- /dev/null +++ b/database/migrations/2025_12_24_005055_increase_bulk_operation_runs_status_length.php @@ -0,0 +1,28 @@ +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(); + }); + } +}; diff --git a/database/seeders/BulkOperationsTestSeeder.php b/database/seeders/BulkOperationsTestSeeder.php new file mode 100644 index 0000000..1229ac5 --- /dev/null +++ b/database/seeders/BulkOperationsTestSeeder.php @@ -0,0 +1,33 @@ +create(); + $user = \App\Models\User::first() ?? \App\Models\User::factory()->create(); + + // Create some policies to test bulk delete + \App\Models\Policy::factory()->count(30)->create([ + 'tenant_id' => $tenant->id, + 'policy_type' => 'deviceConfiguration', + ]); + + // Create a completed bulk run + \App\Models\BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'status' => 'completed', + 'total_items' => 10, + 'processed_items' => 10, + 'succeeded' => 10, + ]); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 996075e..1f9565b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,30 @@ services: - pgsql - 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: image: 'postgres:16' ports: diff --git a/resources/views/livewire/bulk-operation-progress-wrapper.blade.php b/resources/views/livewire/bulk-operation-progress-wrapper.blade.php new file mode 100644 index 0000000..69bcec8 --- /dev/null +++ b/resources/views/livewire/bulk-operation-progress-wrapper.blade.php @@ -0,0 +1 @@ + diff --git a/resources/views/livewire/bulk-operation-progress.blade.php b/resources/views/livewire/bulk-operation-progress.blade.php new file mode 100644 index 0000000..c254211 --- /dev/null +++ b/resources/views/livewire/bulk-operation-progress.blade.php @@ -0,0 +1,75 @@ +
+ + @if($runs->isNotEmpty()) +
+ @foreach ($runs as $run) +
+ +
+
+

+ {{ ucfirst($run->action) }} {{ ucfirst(str_replace('_', ' ', $run->resource)) }} +

+

+ @if($run->status === 'pending') + + + + + + Starting... + + @elseif($run->status === 'running') + + + + + + Processing... + + @endif +

+
+
+ + {{ $run->processed_items }} / {{ $run->total_items }} + +
+ {{ $run->total_items > 0 ? round(($run->processed_items / $run->total_items) * 100) : 0 }}% +
+
+
+ +
+
+
+ +
+
+ @if ($run->succeeded > 0) + + ✓ {{ $run->succeeded }} succeeded + + @endif + @if ($run->failed > 0) + + ✗ {{ $run->failed }} failed + + @endif + @if ($run->skipped > 0) + + ⊘ {{ $run->skipped }} skipped + + @endif +
+ + {{ $run->created_at->diffForHumans(null, true, true) }} + +
+
+ @endforeach +
+ @endif +
diff --git a/specs/005-bulk-operations/contracts/openapi.yaml b/specs/005-bulk-operations/contracts/openapi.yaml new file mode 100644 index 0000000..4a08977 --- /dev/null +++ b/specs/005-bulk-operations/contracts/openapi.yaml @@ -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: {} diff --git a/specs/005-bulk-operations/plan.md b/specs/005-bulk-operations/plan.md index 0a79cf8..9790f69 100644 --- a/specs/005-bulk-operations/plan.md +++ b/specs/005-bulk-operations/plan.md @@ -1,82 +1,38 @@ # 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 -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 + **Language/Version**: PHP 8.4.15 -**Framework**: Laravel 12 -**Primary Dependencies**: -- Filament v4 (admin panel + bulk actions) -- Livewire v3 (reactive UI + polling) -- Laravel Queue (async job processing) -- PostgreSQL (JSONB for tracking) - -**Storage**: PostgreSQL with JSONB fields for: -- `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) +**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3 +**Storage**: PostgreSQL (app), SQLite in-memory (tests) +**Testing**: Pest v4 + PHPUnit 12 +**Target Platform**: Containerized Linux (Sail/Dokploy) +**Project Type**: Web application (Laravel + Filament admin panel) +**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 +**Scale/Scope**: Admin-focused operations, moderate concurrency, emphasis on correctness/auditability over throughput ## Constitution Check *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) -✅ **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) +Re-check after Phase 1: PASS (no new unknowns introduced). ## Project Structure @@ -84,180 +40,42 @@ ### Documentation (this feature) ```text specs/005-bulk-operations/ -├── plan.md # This file -├── research.md # Phase 0 output (see below) -├── data-model.md # Phase 1 output (see below) -├── quickstart.md # Phase 1 output (see below) -└── tasks.md # Phase 2 output (/speckit.tasks command - NOT YET CREATED) +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) ``` ### Source Code (repository root) ```text app/ -├── 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/ │ └── Resources/ -│ ├── PolicyResource.php # EXTEND: Add bulk actions -│ ├── PolicyVersionResource.php # EXTEND: Add bulk prune -│ ├── BackupSetResource.php # EXTEND: Add bulk delete -│ └── RestoreRunResource.php # EXTEND: Add bulk delete -│ -└── Livewire/ - └── BulkOperationProgress.php # NEW: Progress polling component +├── Jobs/ +├── Models/ +└── Services/ database/ +├── factories/ └── migrations/ - └── YYYY_MM_DD_create_bulk_operation_runs_table.php # NEW + +routes/ +├── web.php +└── console.php + +resources/ +└── views/ tests/ -├── Unit/ -│ ├── BulkPolicyDeleteJobTest.php -│ ├── BulkActionPermissionTest.php -│ └── BulkEligibilityCheckTest.php -│ -└── Feature/ - ├── BulkDeletePoliciesTest.php - ├── BulkExportToBackupTest.php - ├── BulkProgressNotificationTest.php - └── BulkTypeToConfirmTest.php +├── Feature/ +└── Unit/ ``` -**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 -> 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 +No constitution violations requiring justification. diff --git a/specs/005-bulk-operations/quickstart.md b/specs/005-bulk-operations/quickstart.md index 000c74f..5712cea 100644 --- a/specs/005-bulk-operations/quickstart.md +++ b/specs/005-bulk-operations/quickstart.md @@ -120,6 +120,17 @@ ### Browser Tests (Pest v4) --- +## Configuration + +These defaults are safe for staging/production, but can be tuned per environment. + +- **Chunk size** (job refresh/progress cadence): + - `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`) +- **Progress polling interval** (UI updates): + - `TENANTPILOT_BULK_POLL_INTERVAL_SECONDS` (default `3`, clamped to 1–10 seconds) +- **Policy version prune retention window**: + - Default `90` days (editable in the prune modal as “Retention Days”) + ## Manual Testing Workflow ### Scenario 1: Bulk Delete Policies (< 20 items) diff --git a/specs/005-bulk-operations/spec.md b/specs/005-bulk-operations/spec.md index 1c3cc37..7d578e0 100644 --- a/specs/005-bulk-operations/spec.md +++ b/specs/005-bulk-operations/spec.md @@ -90,16 +90,16 @@ ### User Story 3 - Bulk Delete Policy Versions (Priority: P2) **Acceptance Criteria:** 1. **Given** I select 30 policy versions older than 90 days, - **When** I click "Delete", - **Then** confirmation dialog: "Delete 30 policy versions? This is permanent and cannot be undone." + **When** I click "Prune (Archive)", + **Then** confirmation dialog: "Archive 30 policy versions? Archived versions can be restored until force-deleted." 2. **Given** I confirm, **When** the operation completes, **Then**: - 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") - - 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 3. **Given** I lack `policy_versions.prune` permission, @@ -299,15 +299,21 @@ ### Policy Versions Resource | 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 | -**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 - 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" +**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`). ### Backup Sets Resource diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md index 1e5ff4d..db57c7e 100644 --- a/specs/005-bulk-operations/tasks.md +++ b/specs/005-bulk-operations/tasks.md @@ -15,12 +15,12 @@ ## Phase 1: Setup (Project Initialization) **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 -- [ ] 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 -- [ ] T004 [P] Create BulkOperationService in app/Services/BulkOperationService.php -- [ ] T005 Run migrations and verify schema: `./vendor/bin/sail artisan migrate` -- [ ] T006 Run Pint formatting: `./vendor/bin/sail composer pint` +- [x] T001 Create migration for `bulk_operation_runs` table in database/migrations/YYYY_MM_DD_HHMMSS_create_bulk_operation_runs_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 +- [x] T003 [P] Create BulkOperationRun model in app/Models/BulkOperationRun.php +- [x] T004 [P] Create BulkOperationService in app/Services/BulkOperationService.php +- [x] T005 Run migrations and verify schema: `./vendor/bin/sail artisan migrate` +- [x] T006 Run Pint formatting: `./vendor/bin/sail composer pint` **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 -- [ ] 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 -- [ ] 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) -- [ ] T011 Update SyncPoliciesJob to filter by `ignored_at IS NULL` in app/Jobs/SyncPoliciesJob.php -- [ ] T012 Create BulkOperationRun factory in database/factories/BulkOperationRunFactory.php -- [ ] T013 Create test seeder BulkOperationsTestSeeder in database/seeders/BulkOperationsTestSeeder.php +- [x] T007 Extend Policy model with `ignored_at` scope and methods in app/Models/Policy.php +- [x] T008 [P] Extend PolicyVersion model with `pruneEligible()` scope in app/Models/PolicyVersion.php +- [x] T009 [P] Extend RestoreRun model with `deletable()` scope in app/Models/RestoreRun.php +- [x] T010 Extend AuditLogger service to support bulk operation events in app/Services/Audit/AuditLogger.php (or equivalent) +- [x] T011 Update SyncPoliciesJob to filter by `ignored_at IS NULL` in app/Jobs/SyncPoliciesJob.php +- [x] T012 Create BulkOperationRun factory in database/factories/BulkOperationRunFactory.php +- [x] T013 Create test seeder BulkOperationsTestSeeder in database/seeders/BulkOperationsTestSeeder.php **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 -- [ ] 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 -- [ ] 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] T014 [P] [US1] Write unit test for BulkPolicyDeleteJob in tests/Unit/BulkPolicyDeleteJobTest.php +- [x] T015 [P] [US1] Write feature test for bulk delete <20 items (sync) in tests/Feature/BulkDeletePoliciesTest.php +- [x] T016 [P] [US1] Write feature test for bulk delete ≥20 items (async) in tests/Feature/BulkDeletePoliciesAsyncTest.php +- [x] T017 [P] [US1] Write permission test for bulk delete in tests/Unit/BulkActionPermissionTest.php ### Implementation for User Story 1 -- [ ] T018 [P] [US1] Create BulkPolicyDeleteJob in app/Jobs/BulkPolicyDeleteJob.php -- [ ] 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 -- [ ] T021 [US1] Wire BulkOperationService to create tracking record and dispatch job -- [ ] T022 [US1] Test bulk delete with 10 policies (sync, manual QA) -- [ ] T023 [US1] Test bulk delete with 25 policies (async, manual QA) -- [ ] T024 [US1] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeletePoliciesTest.php` -- [ ] T025 [US1] Verify audit log entry created with correct metadata +- [x] T018 [P] [US1] Create BulkPolicyDeleteJob in app/Jobs/BulkPolicyDeleteJob.php +- [x] T019 [US1] Add bulk delete action to PolicyResource in app/Filament/Resources/PolicyResource.php +- [x] T020 [US1] Implement type-to-confirm modal (≥20 items) in PolicyResource bulk action +- [x] T021 [US1] Wire BulkOperationService to create tracking record and dispatch job +- [x] T022 [US1] Test bulk delete with 10 policies (sync, manual QA) +- [x] T023 [US1] Test bulk delete with 25 policies (async, manual QA) +- [x] T024 [US1] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeletePoliciesTest.php` +- [x] T025 [US1] Verify audit log entry created with correct metadata **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 -- [ ] 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 -- [ ] T028 [P] [US2] Write test for export with failures (3/25 fail) in tests/Feature/BulkExportFailuresTest.php +- [x] T026 [P] [US2] Write unit test for BulkPolicyExportJob in tests/Unit/BulkPolicyExportJobTest.php +- [x] T027 [P] [US2] Write feature test for bulk export in tests/Feature/BulkExportToBackupTest.php +- [x] T028 [P] [US2] Write test for export with failures (3/25 fail) in tests/Feature/BulkExportFailuresTest.php ### Implementation for User Story 2 -- [ ] T029 [P] [US2] Create BulkPolicyExportJob in app/Jobs/BulkPolicyExportJob.php -- [ ] 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 -- [ ] T032 [US2] Implement export logic (create BackupSet, capture BackupItems) -- [ ] T033 [US2] Handle partial failures (some policies fail to backup) -- [ ] T034 [US2] Test export with 30 policies (manual QA) -- [ ] T035 [US2] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkExportToBackupTest.php` +- [x] T029 [P] [US2] Create BulkPolicyExportJob in app/Jobs/BulkPolicyExportJob.php +- [x] T030 [US2] Add bulk export action to PolicyResource in app/Filament/Resources/PolicyResource.php +- [x] T031 [US2] Create export form with backup_name and include_assignments fields +- [x] T032 [US2] Implement export logic (create BackupSet, capture BackupItems) +- [x] T033 [US2] Handle partial failures (some policies fail to backup) +- [x] T034 [US2] Test export with 30 policies (manual QA) +- [x] T035 [US2] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkExportToBackupTest.php` **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) **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 -- [ ] 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") -- [ ] T038 [P] [US5] Write test for type-to-confirm disabled for <20 items +- [x] T036 [P] [US5] Write test for type-to-confirm with correct input in tests/Feature/BulkTypeToConfirmTest.php +- [x] T037 [P] [US5] Write test for type-to-confirm with incorrect input (lowercase "delete") +- [x] T038 [P] [US5] Write test for type-to-confirm disabled for <20 items ### Validation for User Story 5 -- [ ] T039 [US5] Manual test: Bulk delete 10 policies → confirm without typing (should work) -- [ ] T040 [US5] Manual test: Bulk delete 25 policies → type "delete" → button stays disabled -- [ ] T041 [US5] Manual test: Bulk delete 25 policies → type "DELETE" → button enables, operation proceeds -- [ ] T042 [US5] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkTypeToConfirmTest.php` +- [x] T039 [US5] Manual test: Bulk delete 10 policies → confirm without typing (should work) +- [x] T040 [US5] Manual test: Bulk delete 25 policies → type "delete" → button stays disabled +- [x] T041 [US5] Manual test: Bulk delete 25 policies → type "DELETE" → button enables, operation proceeds +- [x] T042 [US5] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkTypeToConfirmTest.php` **Checkpoint**: Type-to-confirm working correctly for all thresholds @@ -131,21 +150,25 @@ ## Phase 6: User Story 6 - Progress Tracking (Priority: P2) ### Tests for User Story 6 -- [ ] 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 -- [ ] T045 [P] [US6] Write test for circuit breaker (abort >50% fail) in tests/Unit/CircuitBreakerTest.php +- [x] T043 [P] [US6] Write test for progress updates in BulkOperationRun in tests/Unit/BulkOperationRunProgressTest.php +- [x] T044 [P] [US6] Write feature test for progress notification in tests/Feature/BulkProgressNotificationTest.php +- [x] T045 [P] [US6] Write test for circuit breaker (abort >50% fail) in tests/Unit/CircuitBreakerTest.php ### Implementation for User Story 6 -- [ ] 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 -- [ ] T048 [US6] Update BulkPolicyDeleteJob to emit progress after each chunk -- [ ] T049 [US6] Update BulkPolicyExportJob to emit progress after each chunk -- [ ] T050 [US6] Implement circuit breaker logic (abort if >50% fail) in jobs -- [ ] T051 [US6] Add progress polling to Filament notifications or sidebar widget -- [ ] T052 [US6] Test progress with 100 policies (manual QA, observe updates) -- [ ] T053 [US6] Test circuit breaker with mock failures (manual QA) -- [ ] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php` +- [x] T046 [P] [US6] Create Livewire progress component in app/Livewire/BulkOperationProgress.php +- [x] T047 [P] [US6] Create progress view in resources/views/livewire/bulk-operation-progress.blade.php +- [x] T048 [US6] Update BulkPolicyDeleteJob to emit progress after each chunk +- [x] T049 [US6] Update BulkPolicyExportJob to emit progress after each chunk +- [x] T050 [US6] Implement circuit breaker logic (abort if >50% fail) in jobs +- [x] T051 [US6] Add progress polling to Filament notifications or sidebar widget +- [x] T052 [US6] Test progress with 100 policies (manual QA, observe updates) +- [x] T053 [US6] Test circuit breaker with mock failures (manual QA) +- [x] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php` + +- [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 @@ -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) -**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 -- [ ] 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 -- [ ] 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] T055 [P] [US3] Write unit test for pruneEligible() scope in tests/Unit/PolicyVersionEligibilityTest.php +- [x] T056 [P] [US3] Write unit test for BulkPolicyVersionPruneJob in tests/Unit/BulkPolicyVersionPruneJobTest.php +- [x] T057 [P] [US3] Write feature test for bulk prune in tests/Feature/BulkPruneVersionsTest.php +- [x] T058 [P] [US3] Write test for skip reasons (referenced, too recent, current) in tests/Feature/BulkPruneSkipReasonsTest.php ### Implementation for User Story 3 -- [ ] T059 [P] [US3] Create BulkPolicyVersionPruneJob in app/Jobs/BulkPolicyVersionPruneJob.php -- [ ] 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) -- [ ] T062 [US3] Collect skip reasons for ineligible versions -- [ ] T063 [US3] Add type-to-confirm for ≥20 versions -- [ ] T064 [US3] Test prune with 30 versions (15 eligible, 15 ineligible) - manual QA -- [ ] T065 [US3] Verify skip reasons in notification and audit log -- [ ] T066 [US3] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkPruneVersionsTest.php` +- [x] T059 [P] [US3] Create BulkPolicyVersionPruneJob in app/Jobs/BulkPolicyVersionPruneJob.php +- [x] T060 [US3] Add bulk delete action to PolicyVersionResource in app/Filament/Resources/PolicyVersionResource.php +- [x] T061 [US3] Implement eligibility check per version in job (reuse pruneEligible scope) +- [x] T062 [US3] Collect skip reasons for ineligible versions +- [x] T063 [US3] Add type-to-confirm for ≥20 versions +- [x] T064 [US3] Test prune with 30 versions (15 eligible, 15 ineligible) - manual QA +- [x] T065 [US3] Verify skip reasons in notification and audit log +- [x] T066 [US3] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkPruneVersionsTest.php` + +- [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 @@ -187,20 +218,28 @@ ## Phase 8: User Story 4 - Bulk Delete Restore Runs (Priority: P2) ### Tests for User Story 4 -- [ ] T067 [P] [US4] Write unit test for deletable() scope in tests/Unit/RestoreRunDeletableTest.php -- [ ] T068 [P] [US4] Write unit test for BulkRestoreRunDeleteJob in tests/Unit/BulkRestoreRunDeleteJobTest.php -- [ ] T069 [P] [US4] Write feature test for bulk delete restore runs in tests/Feature/BulkDeleteRestoreRunsTest.php -- [ ] T070 [P] [US4] Write test for mixed statuses (skip running) in tests/Feature/BulkDeleteMixedStatusTest.php +- [x] T067 [P] [US4] Write unit test for deletable() scope in tests/Unit/RestoreRunDeletableTest.php +- [x] T068 [P] [US4] Write unit test for BulkRestoreRunDeleteJob in tests/Unit/BulkRestoreRunDeleteJobTest.php +- [x] T069 [P] [US4] Write feature test for bulk delete restore runs in tests/Feature/BulkDeleteRestoreRunsTest.php +- [x] T070 [P] [US4] Write test for mixed statuses (skip running) in tests/Feature/BulkDeleteMixedStatusTest.php ### Implementation for User Story 4 -- [ ] T071 [P] [US4] Create BulkRestoreRunDeleteJob in app/Jobs/BulkRestoreRunDeleteJob.php -- [ ] T072 [US4] Add bulk delete action to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php -- [ ] T073 [US4] Filter by deletable() scope (completed, failed, aborted only) -- [ ] T074 [US4] Skip running restore runs with warning -- [ ] T075 [US4] Add type-to-confirm for ≥20 runs -- [ ] T076 [US4] Test delete with 20 completed + 5 running (manual QA, verify 5 skipped) -- [ ] T077 [US4] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteRestoreRunsTest.php` +- [x] T071 [P] [US4] Create BulkRestoreRunDeleteJob in app/Jobs/BulkRestoreRunDeleteJob.php +- [x] T072 [US4] Add bulk delete action to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php +- [x] T073 [US4] Filter by deletable() scope (completed, failed, aborted only) +- [x] T074 [US4] Skip running restore runs with warning +- [x] T075 [US4] Add type-to-confirm for ≥20 runs +- [x] T076 [US4] Test delete with 20 completed + 5 running (manual QA, verify 5 skipped) +- [x] T077 [US4] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteRestoreRunsTest.php` + +- [x] T077a [US4] Add bulk force delete restore runs job in app/Jobs/BulkRestoreRunForceDeleteJob.php +- [x] T077b [US4] Add bulk force delete action (archived-only) to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php +- [x] T077c [US4] Write feature test for bulk force delete in tests/Feature/BulkForceDeleteRestoreRunsTest.php + +- [x] T077d [US4] Add bulk restore restore runs job in app/Jobs/BulkRestoreRunRestoreJob.php +- [x] T077e [US4] Add bulk restore action (archived-only) to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php +- [x] T077f [US4] Write unit+feature tests for bulk restore in tests/Unit/BulkRestoreRunRestoreJobTest.php and tests/Feature/BulkRestoreRestoreRunsTest.php **Checkpoint**: Restore runs bulk delete working, running runs protected, skip warnings shown @@ -214,17 +253,32 @@ ## Phase 9: Additional Resource - Bulk Delete Backup Sets (Priority: P2) ### Tests for Additional Resource -- [ ] T078 [P] Write unit test for BulkBackupSetDeleteJob in tests/Unit/BulkBackupSetDeleteJobTest.php -- [ ] T079 [P] Write feature test for bulk delete backup sets in tests/Feature/BulkDeleteBackupSetsTest.php +- [x] T078 [P] Write unit test for BulkBackupSetDeleteJob in tests/Unit/BulkBackupSetDeleteJobTest.php +- [x] T079 [P] Write feature test for bulk delete backup sets in tests/Feature/BulkDeleteBackupSetsTest.php ### Implementation for Additional Resource -- [ ] T080 [P] Create BulkBackupSetDeleteJob in app/Jobs/BulkBackupSetDeleteJob.php -- [ ] T081 Add bulk delete action to BackupSetResource in app/Filament/Resources/BackupSetResource.php -- [ ] T082 Verify cascade-delete logic for backup items (should be automatic via foreign key) -- [ ] T083 Add type-to-confirm for ≥10 sets -- [ ] T084 Test delete with 15 backup sets (manual QA) -- [ ] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php` +- [x] T080 [P] Create BulkBackupSetDeleteJob in app/Jobs/BulkBackupSetDeleteJob.php +- [x] T081 Add bulk delete action to BackupSetResource in app/Filament/Resources/BackupSetResource.php +- [x] T082 Verify cascade-delete logic for backup items (should be automatic via foreign key) +- [x] T083 Add type-to-confirm for ≥10 sets +- [x] T084 Test delete with 15 backup sets (manual QA) +- [x] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php` + +- [x] T085e Add bulk restore backup sets job in app/Jobs/BulkBackupSetRestoreJob.php +- [x] T085f Add bulk restore action (archived-only) to BackupSetResource in app/Filament/Resources/BackupSetResource.php +- [x] T085g Write unit+feature tests for bulk restore in tests/Unit/BulkBackupSetRestoreJobTest.php and tests/Feature/BulkRestoreBackupSetsTest.php + +- [x] T085h Add bulk force delete backup sets job in app/Jobs/BulkBackupSetForceDeleteJob.php +- [x] T085i Add bulk force delete action (archived-only) to BackupSetResource in app/Filament/Resources/BackupSetResource.php +- [x] T085j Write unit+feature tests for bulk force delete in tests/Unit/BulkBackupSetForceDeleteJobTest.php and tests/Feature/BulkForceDeleteBackupSetsTest.php + +### Additional: Bulk Archive Backup Sets (FR-005.23) + +- [ ] 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 @@ -234,17 +288,17 @@ ## Phase 10: Polish & Cross-Cutting Concerns **Purpose**: Documentation, cleanup, performance optimization -- [ ] T086 [P] Update README.md with bulk operations feature description -- [ ] T087 [P] Update quickstart.md with manual testing scenarios (already done in planning) +- [x] T086 [P] Update README.md with bulk operations feature description +- [x] T087 [P] Update quickstart.md with manual testing scenarios (already done in planning) - [ ] T088 Code cleanup: Remove debug statements, refactor duplicated logic - [ ] T089 Performance test: Bulk delete 500 policies (should complete <5 minutes) - [ ] T090 Load test: Concurrent bulk operations (2-3 admins, different resources) - [ ] T091 [P] Security review: Verify tenant isolation in all jobs - [ ] T092 [P] Permission audit: Verify all bulk actions respect RBAC -- [ ] T093 Run full test suite: `./vendor/bin/sail artisan test` -- [ ] T094 Run Pint formatting: `./vendor/bin/sail composer pint` -- [ ] T095 Manual QA checklist: Complete all scenarios from quickstart.md -- [ ] T096 Document configuration options (chunk size, polling interval, retention days) +- [x] T093 Run full test suite: `./vendor/bin/sail artisan test` +- [x] T094 Run Pint formatting: `./vendor/bin/sail composer pint` +- [x] T095 Manual QA checklist: Complete all scenarios from quickstart.md +- [x] T096 Document configuration options (chunk size, polling interval, retention days) - [ ] T097 Create BulkOperationRun resource page in Filament (view progress, retry failed) - [ ] T098 Add bulk operation metrics to dashboard (total runs, success rate) diff --git a/specs/005-policy-lifecycle/spec.md b/specs/900-policy-lifecycle/spec.md similarity index 100% rename from specs/005-policy-lifecycle/spec.md rename to specs/900-policy-lifecycle/spec.md diff --git a/tests/Feature/BulkDeleteBackupSetsTest.php b/tests/Feature/BulkDeleteBackupSetsTest.php new file mode 100644 index 0000000..260b458 --- /dev/null +++ b/tests/Feature/BulkDeleteBackupSetsTest.php @@ -0,0 +1,113 @@ +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(); +}); diff --git a/tests/Feature/BulkDeleteMixedStatusTest.php b/tests/Feature/BulkDeleteMixedStatusTest.php new file mode 100644 index 0000000..17dad2b --- /dev/null +++ b/tests/Feature/BulkDeleteMixedStatusTest.php @@ -0,0 +1,62 @@ +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); +}); diff --git a/tests/Feature/BulkDeletePoliciesAsyncTest.php b/tests/Feature/BulkDeletePoliciesAsyncTest.php new file mode 100644 index 0000000..754b094 --- /dev/null +++ b/tests/Feature/BulkDeletePoliciesAsyncTest.php @@ -0,0 +1,30 @@ +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; + }); +}); diff --git a/tests/Feature/BulkDeletePoliciesTest.php b/tests/Feature/BulkDeletePoliciesTest.php new file mode 100644 index 0000000..4a2c72f --- /dev/null +++ b/tests/Feature/BulkDeletePoliciesTest.php @@ -0,0 +1,34 @@ +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(); + }); +}); diff --git a/tests/Feature/BulkDeleteRestoreRunsTest.php b/tests/Feature/BulkDeleteRestoreRunsTest.php new file mode 100644 index 0000000..9e41b4b --- /dev/null +++ b/tests/Feature/BulkDeleteRestoreRunsTest.php @@ -0,0 +1,51 @@ +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'); +}); diff --git a/tests/Feature/BulkExportFailuresTest.php b/tests/Feature/BulkExportFailuresTest.php new file mode 100644 index 0000000..3fd0cf7 --- /dev/null +++ b/tests/Feature/BulkExportFailuresTest.php @@ -0,0 +1,51 @@ +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', + ]); +}); diff --git a/tests/Feature/BulkExportToBackupTest.php b/tests/Feature/BulkExportToBackupTest.php new file mode 100644 index 0000000..e315aea --- /dev/null +++ b/tests/Feature/BulkExportToBackupTest.php @@ -0,0 +1,41 @@ +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, + ]); +}); diff --git a/tests/Feature/BulkForceDeleteBackupSetsTest.php b/tests/Feature/BulkForceDeleteBackupSetsTest.php new file mode 100644 index 0000000..7cb8aec --- /dev/null +++ b/tests/Feature/BulkForceDeleteBackupSetsTest.php @@ -0,0 +1,56 @@ +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'); +}); diff --git a/tests/Feature/BulkForceDeletePolicyVersionsTest.php b/tests/Feature/BulkForceDeletePolicyVersionsTest.php new file mode 100644 index 0000000..106239b --- /dev/null +++ b/tests/Feature/BulkForceDeletePolicyVersionsTest.php @@ -0,0 +1,49 @@ +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(); +}); diff --git a/tests/Feature/BulkForceDeleteRestoreRunsTest.php b/tests/Feature/BulkForceDeleteRestoreRunsTest.php new file mode 100644 index 0000000..d527954 --- /dev/null +++ b/tests/Feature/BulkForceDeleteRestoreRunsTest.php @@ -0,0 +1,58 @@ +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'); +}); diff --git a/tests/Feature/BulkProgressNotificationTest.php b/tests/Feature/BulkProgressNotificationTest.php new file mode 100644 index 0000000..d01960e --- /dev/null +++ b/tests/Feature/BulkProgressNotificationTest.php @@ -0,0 +1,51 @@ +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'); +}); diff --git a/tests/Feature/BulkPruneSkipReasonsTest.php b/tests/Feature/BulkPruneSkipReasonsTest.php new file mode 100644 index 0000000..759ff54 --- /dev/null +++ b/tests/Feature/BulkPruneSkipReasonsTest.php @@ -0,0 +1,61 @@ +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'); +}); diff --git a/tests/Feature/BulkPruneVersionsTest.php b/tests/Feature/BulkPruneVersionsTest.php new file mode 100644 index 0000000..ec62444 --- /dev/null +++ b/tests/Feature/BulkPruneVersionsTest.php @@ -0,0 +1,44 @@ +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(); +}); diff --git a/tests/Feature/BulkRestoreBackupSetsTest.php b/tests/Feature/BulkRestoreBackupSetsTest.php new file mode 100644 index 0000000..3908e6d --- /dev/null +++ b/tests/Feature/BulkRestoreBackupSetsTest.php @@ -0,0 +1,59 @@ +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'); +}); diff --git a/tests/Feature/BulkRestorePolicyVersionsTest.php b/tests/Feature/BulkRestorePolicyVersionsTest.php new file mode 100644 index 0000000..41d6a81 --- /dev/null +++ b/tests/Feature/BulkRestorePolicyVersionsTest.php @@ -0,0 +1,48 @@ +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(); +}); diff --git a/tests/Feature/BulkRestoreRestoreRunsTest.php b/tests/Feature/BulkRestoreRestoreRunsTest.php new file mode 100644 index 0000000..35d2bec --- /dev/null +++ b/tests/Feature/BulkRestoreRestoreRunsTest.php @@ -0,0 +1,58 @@ +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(); +}); diff --git a/tests/Feature/BulkSyncPoliciesTest.php b/tests/Feature/BulkSyncPoliciesTest.php new file mode 100644 index 0000000..6e976e0 --- /dev/null +++ b/tests/Feature/BulkSyncPoliciesTest.php @@ -0,0 +1,93 @@ +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(); +}); diff --git a/tests/Feature/BulkTypeToConfirmTest.php b/tests/Feature/BulkTypeToConfirmTest.php new file mode 100644 index 0000000..1b7748a --- /dev/null +++ b/tests/Feature/BulkTypeToConfirmTest.php @@ -0,0 +1,56 @@ +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()); +}); diff --git a/tests/Feature/BulkUnignorePoliciesTest.php b/tests/Feature/BulkUnignorePoliciesTest.php new file mode 100644 index 0000000..fa53b64 --- /dev/null +++ b/tests/Feature/BulkUnignorePoliciesTest.php @@ -0,0 +1,41 @@ +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(); + }); +}); diff --git a/tests/Feature/Filament/HousekeepingTest.php b/tests/Feature/Filament/HousekeepingTest.php index 25d44b5..9693d8b 100644 --- a/tests/Feature/Filament/HousekeepingTest.php +++ b/tests/Feature/Filament/HousekeepingTest.php @@ -1,6 +1,7 @@ 'tenant-2', 'name' => 'Tenant 2', @@ -65,7 +66,7 @@ 'status' => 'completed', ]); - RestoreRun::create([ + $restoreRun = RestoreRun::create([ 'tenant_id' => $tenant->id, 'backup_set_id' => $backupSet->id, 'status' => 'completed', @@ -77,12 +78,13 @@ Livewire::test(ListBackupSets::class) ->callTableAction('archive', $backupSet); - $this->assertDatabaseMissing('audit_logs', [ + $this->assertSoftDeleted('backup_sets', ['id' => $backupSet->id]); + $this->assertDatabaseHas('audit_logs', [ 'resource_type' => 'backup_set', 'resource_id' => (string) $backupSet->id, '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 () { @@ -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 () { $tenant = Tenant::create([ '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 () { $tenant = Tenant::create([ 'tenant_id' => 'tenant-3', 'name' => 'Tenant 3', ]); + $tenant->makeCurrent(); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'pol-1', @@ -201,6 +315,8 @@ 'name' => 'Tenant 3b', ]); + $tenant->makeCurrent(); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'pol-1b', diff --git a/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php b/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php new file mode 100644 index 0000000..d6a50ab --- /dev/null +++ b/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php @@ -0,0 +1,159 @@ + $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(); +}); diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php index eb977a6..239ff72 100644 --- a/tests/Feature/PolicyVersionViewAssignmentsTest.php +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -6,7 +6,11 @@ use App\Models\User; beforeEach(function () { + putenv('INTUNE_TENANT_ID'); + unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']); + $this->tenant = Tenant::factory()->create(); + $this->tenant->makeCurrent(); $this->policy = Policy::factory()->create([ 'tenant_id' => $this->tenant->id, ]); diff --git a/tests/Feature/RestoreRunArchiveGuardTest.php b/tests/Feature/RestoreRunArchiveGuardTest.php new file mode 100644 index 0000000..50c7c57 --- /dev/null +++ b/tests/Feature/RestoreRunArchiveGuardTest.php @@ -0,0 +1,37 @@ +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(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index d03d034..4baf965 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -17,6 +17,11 @@ ->use(RefreshDatabase::class) ->in('Feature'); +beforeEach(function () { + putenv('INTUNE_TENANT_ID'); + unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']); +}); + /* |-------------------------------------------------------------------------- | Expectations diff --git a/tests/Unit/BulkActionPermissionTest.php b/tests/Unit/BulkActionPermissionTest.php new file mode 100644 index 0000000..a9da5b9 --- /dev/null +++ b/tests/Unit/BulkActionPermissionTest.php @@ -0,0 +1,23 @@ +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(); +}); diff --git a/tests/Unit/BulkBackupSetDeleteJobTest.php b/tests/Unit/BulkBackupSetDeleteJobTest.php new file mode 100644 index 0000000..03c9e4c --- /dev/null +++ b/tests/Unit/BulkBackupSetDeleteJobTest.php @@ -0,0 +1,91 @@ +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(); +}); diff --git a/tests/Unit/BulkBackupSetForceDeleteJobTest.php b/tests/Unit/BulkBackupSetForceDeleteJobTest.php new file mode 100644 index 0000000..7a2720a --- /dev/null +++ b/tests/Unit/BulkBackupSetForceDeleteJobTest.php @@ -0,0 +1,107 @@ +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'); +}); diff --git a/tests/Unit/BulkBackupSetRestoreJobTest.php b/tests/Unit/BulkBackupSetRestoreJobTest.php new file mode 100644 index 0000000..7966e4c --- /dev/null +++ b/tests/Unit/BulkBackupSetRestoreJobTest.php @@ -0,0 +1,105 @@ +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'); +}); diff --git a/tests/Unit/BulkOperationAbortMethodTest.php b/tests/Unit/BulkOperationAbortMethodTest.php new file mode 100644 index 0000000..de3b92d --- /dev/null +++ b/tests/Unit/BulkOperationAbortMethodTest.php @@ -0,0 +1,24 @@ +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'); +}); diff --git a/tests/Unit/BulkOperationRunProgressTest.php b/tests/Unit/BulkOperationRunProgressTest.php new file mode 100644 index 0000000..039b52f --- /dev/null +++ b/tests/Unit/BulkOperationRunProgressTest.php @@ -0,0 +1,33 @@ +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); +}); diff --git a/tests/Unit/BulkPolicyDeleteJobTest.php b/tests/Unit/BulkPolicyDeleteJobTest.php new file mode 100644 index 0000000..37ca156 --- /dev/null +++ b/tests/Unit/BulkPolicyDeleteJobTest.php @@ -0,0 +1,61 @@ +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'); +}); diff --git a/tests/Unit/BulkPolicyExportJobTest.php b/tests/Unit/BulkPolicyExportJobTest.php new file mode 100644 index 0000000..88dfb9a --- /dev/null +++ b/tests/Unit/BulkPolicyExportJobTest.php @@ -0,0 +1,88 @@ +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'); +}); diff --git a/tests/Unit/BulkPolicyVersionForceDeleteJobTest.php b/tests/Unit/BulkPolicyVersionForceDeleteJobTest.php new file mode 100644 index 0000000..a90f55e --- /dev/null +++ b/tests/Unit/BulkPolicyVersionForceDeleteJobTest.php @@ -0,0 +1,69 @@ +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); +}); diff --git a/tests/Unit/BulkPolicyVersionPruneJobTest.php b/tests/Unit/BulkPolicyVersionPruneJobTest.php new file mode 100644 index 0000000..5151902 --- /dev/null +++ b/tests/Unit/BulkPolicyVersionPruneJobTest.php @@ -0,0 +1,120 @@ +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'); +}); diff --git a/tests/Unit/BulkPolicyVersionRestoreJobTest.php b/tests/Unit/BulkPolicyVersionRestoreJobTest.php new file mode 100644 index 0000000..fc992a6 --- /dev/null +++ b/tests/Unit/BulkPolicyVersionRestoreJobTest.php @@ -0,0 +1,88 @@ +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'); +}); diff --git a/tests/Unit/BulkRestoreRunDeleteJobTest.php b/tests/Unit/BulkRestoreRunDeleteJobTest.php new file mode 100644 index 0000000..fc3e6c6 --- /dev/null +++ b/tests/Unit/BulkRestoreRunDeleteJobTest.php @@ -0,0 +1,94 @@ +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(); +}); diff --git a/tests/Unit/BulkRestoreRunRestoreJobTest.php b/tests/Unit/BulkRestoreRunRestoreJobTest.php new file mode 100644 index 0000000..3e53f64 --- /dev/null +++ b/tests/Unit/BulkRestoreRunRestoreJobTest.php @@ -0,0 +1,102 @@ +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'); +}); diff --git a/tests/Unit/CircuitBreakerTest.php b/tests/Unit/CircuitBreakerTest.php new file mode 100644 index 0000000..b1bb4ef --- /dev/null +++ b/tests/Unit/CircuitBreakerTest.php @@ -0,0 +1,47 @@ +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', + ]); +}); diff --git a/tests/Unit/PolicyVersionEligibilityTest.php b/tests/Unit/PolicyVersionEligibilityTest.php new file mode 100644 index 0000000..daac299 --- /dev/null +++ b/tests/Unit/PolicyVersionEligibilityTest.php @@ -0,0 +1,84 @@ +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); +}); diff --git a/tests/Unit/RestoreRunDeletableTest.php b/tests/Unit/RestoreRunDeletableTest.php new file mode 100644 index 0000000..fa61f1c --- /dev/null +++ b/tests/Unit/RestoreRunDeletableTest.php @@ -0,0 +1,88 @@ +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(); +});