feat/005-bulk-operations #5

Merged
ahmido merged 25 commits from feat/005-bulk-operations into dev 2025-12-25 13:32:37 +00:00
52 changed files with 3460 additions and 62 deletions
Showing only changes of commit 15bf633e57 - Show all commits

View File

@ -4,21 +4,31 @@
use App\Filament\Resources\PolicyResource\Pages; use App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager; use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Jobs\BulkPolicyDeleteJob;
use App\Jobs\BulkPolicyExportJob;
use App\Jobs\BulkPolicyUnignoreJob;
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\PolicyNormalizer;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Forms;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry; use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use UnitEnum; use UnitEnum;
class PolicyResource extends Resource class PolicyResource extends Resource
@ -253,6 +263,30 @@ public static function table(Table $table): Table
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
]) ])
->filters([ ->filters([
Tables\Filters\SelectFilter::make('visibility')
->label('Visibility')
->options([
'active' => 'Active',
'ignored' => 'Ignored',
])
->default('active')
->query(function (Builder $query, array $data) {
$value = $data['value'] ?? null;
if (blank($value)) {
return;
}
if ($value === 'active') {
$query->whereNull('ignored_at');
return;
}
if ($value === 'ignored') {
$query->whereNotNull('ignored_at');
}
}),
Tables\Filters\SelectFilter::make('policy_type') Tables\Filters\SelectFilter::make('policy_type')
->options(function () { ->options(function () {
return collect(config('tenantpilot.supported_policy_types', [])) return collect(config('tenantpilot.supported_policy_types', []))
@ -283,12 +317,146 @@ public static function table(Table $table): Table
$query->whereIn('policy_type', $types); $query->whereIn('policy_type', $types);
}), }),
Tables\Filters\SelectFilter::make('platform') Tables\Filters\SelectFilter::make('platform')
->options(fn () => Policy::query()->distinct()->pluck('platform', 'platform')->filter()->all()), ->options(fn () => Policy::query()
->distinct()
->pluck('platform', 'platform')
->filter()
->reject(fn ($platform) => is_string($platform) && strtolower($platform) === 'all')
->all()),
]) ])
->actions([ ->actions([
Actions\ViewAction::make(), Actions\ViewAction::make(),
]) ])
->bulkActions([]); ->bulkActions([
BulkActionGroup::make([
BulkAction::make('bulk_delete')
->label('Delete 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_export')
->label('Export to Backup')
->icon('heroicon-o-archive-box-arrow-down')
->form([
Forms\Components\TextInput::make('backup_name')
->label('Backup Name')
->required()
->default(fn () => 'Backup '.now()->toDateTimeString()),
])
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'export', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk export started')
->body("Exporting {$count} policies to backup '{$data['backup_name']}' in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyExportJob::dispatch($run->id, $data['backup_name']);
} else {
BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']);
}
})
->deselectRecordsAfterCompletion(),
]),
]);
} }
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder

View File

@ -34,12 +34,14 @@ protected function getHeaderActions(): array
->title('Policy sync completed') ->title('Policy sync completed')
->body(count($synced).' policies synced') ->body(count($synced).' policies synced')
->success() ->success()
->sendToDatabase(auth()->user())
->send(); ->send();
} catch (\Throwable $e) { } catch (\Throwable $e) {
Notification::make() Notification::make()
->title('Policy sync failed') ->title('Policy sync failed')
->body($e->getMessage()) ->body($e->getMessage())
->danger() ->danger()
->sendToDatabase(auth()->user())
->send(); ->send();
} }
}), }),

View File

@ -3,12 +3,20 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\PolicyVersionResource\Pages; use App\Filament\Resources\PolicyVersionResource\Pages;
use App\Jobs\BulkPolicyVersionForceDeleteJob;
use App\Jobs\BulkPolicyVersionPruneJob;
use App\Jobs\BulkPolicyVersionRestoreJob;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\PolicyNormalizer;
use App\Services\Intune\VersionDiff; use App\Services\Intune\VersionDiff;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Forms;
use Filament\Infolists; use Filament\Infolists;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
@ -16,7 +24,11 @@
use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use UnitEnum; use UnitEnum;
class PolicyVersionResource extends Resource class PolicyVersionResource extends Resource
@ -112,7 +124,11 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(), Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
]) ])
->filters([ ->filters([
Tables\Filters\TrashedFilter::make(), TrashedFilter::make()
->label('Archived')
->placeholder('Active')
->trueLabel('All')
->falseLabel('Archived'),
]) ])
->actions([ ->actions([
Actions\ViewAction::make() Actions\ViewAction::make()
@ -169,9 +185,205 @@ public static function table(Table $table): Table
->success() ->success()
->send(); ->send();
}), }),
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (PolicyVersion $record) => $record->trashed())
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
$record->restore();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'policy_version.restored',
resourceType: 'policy_version',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
);
}
Notification::make()
->title('Policy version restored')
->success()
->send();
}),
])->icon('heroicon-o-ellipsis-vertical'), ])->icon('heroicon-o-ellipsis-vertical'),
]) ])
->bulkActions([]); ->bulkActions([
BulkActionGroup::make([
BulkAction::make('bulk_prune_versions')
->label('Prune Versions')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return $isOnlyTrashed;
})
->form(function (Collection $records) {
$fields = [
Forms\Components\TextInput::make('retention_days')
->label('Retention Days')
->helperText('Versions captured within the last N days will be skipped.')
->numeric()
->required()
->default(90)
->minValue(1),
];
if ($records->count() >= 20) {
$fields[] = Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]);
}
return $fields;
})
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$retentionDays = (int) ($data['retention_days'] ?? 90);
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'prune', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk prune started')
->body("Pruning {$count} policy versions in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyVersionPruneJob::dispatch($run->id, $retentionDays);
} else {
BulkPolicyVersionPruneJob::dispatchSync($run->id, $retentionDays);
}
})
->deselectRecordsAfterCompletion(),
BulkAction::make('bulk_restore_versions')
->label('Restore Versions')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'restore', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk restore started')
->body("Restoring {$count} policy versions in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyVersionRestoreJob::dispatch($run->id);
} else {
BulkPolicyVersionRestoreJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
BulkAction::make('bulk_force_delete_versions')
->label('Force Delete Versions')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
->form([
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
])
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk force delete started')
->body("Force deleting {$count} policy versions in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyVersionForceDeleteJob::dispatch($run->id);
} else {
BulkPolicyVersionForceDeleteJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
]),
]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->with('policy');
} }
public static function getPages(): array public static function getPages(): array

View File

@ -0,0 +1,185 @@
<?php
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class BulkPolicyDeleteJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $bulkRunId
) {}
public function handle(BulkOperationService $service): void
{
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
if (! $run || $run->status !== 'pending') {
return;
}
$service->start($run);
try {
$itemCount = 0;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$failures = [];
$chunkSize = 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 10 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;
}
}
}

View File

@ -0,0 +1,219 @@
<?php
namespace App\Jobs;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class BulkPolicyExportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $bulkRunId,
public string $backupName,
public ?string $backupDescription = null
) {}
public function handle(BulkOperationService $service): void
{
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
if (! $run || $run->status !== 'pending') {
return;
}
$service->start($run);
try {
// Create Backup Set
$backupSet = BackupSet::create([
'tenant_id' => $run->tenant_id,
'name' => $this->backupName,
// 'description' => $this->backupDescription, // Not in schema
'status' => 'completed',
'created_by' => $run->user?->name ?? (string) $run->user_id, // Schema has created_by string
'item_count' => count($run->item_ids),
'completed_at' => now(),
]);
$itemCount = 0;
$succeeded = 0;
$failed = 0;
$failures = [];
$chunkSize = 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;
}
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class BulkPolicyUnignoreJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $bulkRunId) {}
public function handle(BulkOperationService $service): void
{
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
if (! $run || $run->status !== 'pending') {
return;
}
$service->start($run);
try {
$itemCount = 0;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$chunkSize = 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;
}
}
}

View File

@ -0,0 +1,148 @@
<?php
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\PolicyVersion;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class BulkPolicyVersionForceDeleteJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $bulkRunId,
) {}
public function handle(BulkOperationService $service): void
{
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
if (! $run || $run->status !== 'pending') {
return;
}
$service->start($run);
$itemCount = 0;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$chunkSize = 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();
}
}
}

View File

@ -0,0 +1,178 @@
<?php
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\PolicyVersion;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class BulkPolicyVersionPruneJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $bulkRunId,
public int $retentionDays = 90,
) {}
public function handle(BulkOperationService $service): void
{
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
if (! $run || $run->status !== 'pending') {
return;
}
$service->start($run);
$itemCount = 0;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$chunkSize = 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();
}
}
}

View File

@ -0,0 +1,148 @@
<?php
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\PolicyVersion;
use App\Services\BulkOperationService;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class BulkPolicyVersionRestoreJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $bulkRunId,
) {}
public function handle(BulkOperationService $service): void
{
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
if (! $run || $run->status !== 'pending') {
return;
}
$service->start($run);
$itemCount = 0;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$skipReasons = [];
$chunkSize = 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();
}
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Livewire;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use Livewire\Attributes\Computed;
use Livewire\Component;
class BulkOperationProgress extends Component
{
public $runs;
public function mount()
{
$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');
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BulkOperationRun extends Model
{
use HasFactory;
protected $fillable = [
'tenant_id',
'user_id',
'resource',
'action',
'status',
'total_items',
'processed_items',
'succeeded',
'failed',
'skipped',
'item_ids',
'failures',
'audit_log_id',
];
protected $casts = [
'item_ids' => 'array',
'failures' => 'array',
'processed_items' => 'integer',
'total_items' => 'integer',
'succeeded' => 'integer',
'failed' => 'integer',
'skipped' => 'integer',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function auditLog(): BelongsTo
{
return $this->belongsTo(AuditLog::class);
}
}

View File

@ -18,6 +18,7 @@ class Policy extends Model
protected $casts = [ protected $casts = [
'metadata' => 'array', 'metadata' => 'array',
'last_synced_at' => 'datetime', 'last_synced_at' => 'datetime',
'ignored_at' => 'datetime',
]; ];
public function tenant(): BelongsTo public function tenant(): BelongsTo
@ -34,4 +35,24 @@ public function backupItems(): HasMany
{ {
return $this->hasMany(BackupItem::class); return $this->hasMany(BackupItem::class);
} }
public function scopeActive($query)
{
return $query->whereNull('ignored_at');
}
public function scopeIgnored($query)
{
return $query->whereNotNull('ignored_at');
}
public function ignore(): void
{
$this->update(['ignored_at' => now()]);
}
public function unignore(): void
{
$this->update(['ignored_at' => null]);
}
} }

View File

@ -40,4 +40,14 @@ public function policy(): BelongsTo
{ {
return $this->belongsTo(Policy::class); return $this->belongsTo(Policy::class);
} }
public function scopePruneEligible($query, int $days = 90)
{
return $query
->whereNull('deleted_at')
->where('captured_at', '<', now()->subDays($days))
->whereRaw(
'policy_versions.version_number < (select max(pv2.version_number) from policy_versions pv2 where pv2.policy_id = policy_versions.policy_id and pv2.deleted_at is null)'
);
}
} }

View File

@ -33,4 +33,9 @@ public function backupSet(): BelongsTo
{ {
return $this->belongsTo(BackupSet::class); return $this->belongsTo(BackupSet::class);
} }
public function scopeDeletable($query)
{
return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors']);
}
} }

View File

@ -10,6 +10,7 @@
use Filament\Panel; use Filament\Panel;
use Filament\PanelProvider; use Filament\PanelProvider;
use Filament\Support\Colors\Color; use Filament\Support\Colors\Color;
use Filament\View\PanelsRenderHook;
use Filament\Widgets\AccountWidget; use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget; use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
@ -31,6 +32,10 @@ public function panel(Panel $panel): Panel
->colors([ ->colors([
'primary' => Color::Amber, 'primary' => Color::Amber,
]) ])
->renderHook(
PanelsRenderHook::BODY_END,
fn () => view('livewire.bulk-operation-progress-wrapper')->render()
)
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([ ->pages([
@ -41,6 +46,7 @@ public function panel(Panel $panel): Panel
AccountWidget::class, AccountWidget::class,
FilamentInfoWidget::class, FilamentInfoWidget::class,
]) ])
->databaseNotifications()
->middleware([ ->middleware([
EncryptCookies::class, EncryptCookies::class,
AddQueuedCookiesToResponse::class, AddQueuedCookiesToResponse::class,

View File

@ -0,0 +1,189 @@
<?php
namespace App\Services;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\AuditLogger;
class BulkOperationService
{
public function __construct(
protected AuditLogger $auditLogger
) {}
public function createRun(
Tenant $tenant,
User $user,
string $resource,
string $action,
array $itemIds,
int $totalItems
): BulkOperationRun {
$run = BulkOperationRun::create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'resource' => $resource,
'action' => $action,
'status' => 'pending',
'item_ids' => $itemIds,
'total_items' => $totalItems,
'processed_items' => 0,
'succeeded' => 0,
'failed' => 0,
'skipped' => 0,
'failures' => [],
]);
$auditLog = $this->auditLogger->log(
tenant: $tenant,
action: "bulk.{$resource}.{$action}.created",
context: [
'metadata' => [
'bulk_run_id' => $run->id,
'total_items' => $totalItems,
],
],
actorId: $user->id,
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'bulk_operation_run',
resourceId: (string) $run->id
);
$run->update(['audit_log_id' => $auditLog->id]);
return $run;
}
public function start(BulkOperationRun $run): void
{
$run->update(['status' => 'running']);
}
public function recordSuccess(BulkOperationRun $run): void
{
$run->increment('processed_items');
$run->increment('succeeded');
}
public function recordFailure(BulkOperationRun $run, string $itemId, string $reason): void
{
$failures = $run->failures ?? [];
$failures[] = [
'item_id' => $itemId,
'reason' => $reason,
'timestamp' => now()->toIso8601String(),
];
$run->update([
'failures' => $failures,
'processed_items' => $run->processed_items + 1,
'failed' => $run->failed + 1,
]);
}
public function recordSkipped(BulkOperationRun $run): void
{
$run->increment('processed_items');
$run->increment('skipped');
}
public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, string $reason): void
{
$failures = $run->failures ?? [];
$failures[] = [
'item_id' => $itemId,
'reason' => $reason,
'type' => 'skipped',
'timestamp' => now()->toIso8601String(),
];
$run->update([
'failures' => $failures,
'processed_items' => $run->processed_items + 1,
'skipped' => $run->skipped + 1,
]);
}
public function complete(BulkOperationRun $run): void
{
$status = $run->failed > 0 ? 'completed_with_errors' : 'completed';
$run->update(['status' => $status]);
$failureEntries = collect($run->failures ?? []);
$failedReasons = $failureEntries
->filter(fn (array $entry) => ($entry['type'] ?? 'failed') !== 'skipped')
->groupBy('reason')
->map(fn ($group) => $group->count())
->all();
$skippedReasons = $failureEntries
->filter(fn (array $entry) => ($entry['type'] ?? null) === 'skipped')
->groupBy('reason')
->map(fn ($group) => $group->count())
->all();
$this->auditLogger->log(
tenant: $run->tenant,
action: "bulk.{$run->resource}.{$run->action}.{$status}",
context: [
'metadata' => [
'bulk_run_id' => $run->id,
'succeeded' => $run->succeeded,
'failed' => $run->failed,
'skipped' => $run->skipped,
'failed_reasons' => $failedReasons,
'skipped_reasons' => $skippedReasons,
],
],
actorId: $run->user_id,
resourceType: 'bulk_operation_run',
resourceId: (string) $run->id
);
}
public function fail(BulkOperationRun $run, string $reason): void
{
$run->update(['status' => 'failed']);
$this->auditLogger->log(
tenant: $run->tenant,
action: "bulk.{$run->resource}.{$run->action}.failed",
context: [
'reason' => $reason,
'metadata' => [
'bulk_run_id' => $run->id,
],
],
actorId: $run->user_id,
status: 'failure',
resourceType: 'bulk_operation_run',
resourceId: (string) $run->id
);
}
public function abort(BulkOperationRun $run, string $reason): void
{
$run->update(['status' => 'aborted']);
$this->auditLogger->log(
tenant: $run->tenant,
action: "bulk.{$run->resource}.{$run->action}.aborted",
context: [
'reason' => $reason,
'metadata' => [
'bulk_run_id' => $run->id,
'succeeded' => $run->succeeded,
'failed' => $run->failed,
'skipped' => $run->skipped,
],
],
actorId: $run->user_id,
status: 'failure',
resourceType: 'bulk_operation_run',
resourceId: (string) $run->id
);
}
}

View File

@ -97,6 +97,7 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
'display_name' => $displayName, 'display_name' => $displayName,
'platform' => $policyPlatform, 'platform' => $policyPlatform,
'last_synced_at' => now(), 'last_synced_at' => now(),
'ignored_at' => null,
'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']), 'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']),
] ]
); );

View File

@ -0,0 +1,34 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BulkOperationRun>
*/
class BulkOperationRunFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'tenant_id' => \App\Models\Tenant::factory(),
'user_id' => \App\Models\User::factory(),
'resource' => 'policy',
'action' => 'delete',
'status' => 'pending',
'total_items' => 10,
'processed_items' => 0,
'succeeded' => 0,
'failed' => 0,
'skipped' => 0,
'item_ids' => range(1, 10),
'failures' => [],
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Policy>
*/
class PolicyFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'tenant_id' => \App\Models\Tenant::factory(),
'external_id' => fake()->uuid(),
'display_name' => fake()->words(3, true),
'policy_type' => 'deviceConfiguration',
'platform' => 'windows10AndLater',
'metadata' => ['key' => 'value'],
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Database\Factories;
use App\Models\Policy;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PolicyVersion>
*/
class PolicyVersionFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'policy_id' => Policy::factory(),
'version_number' => 1,
'policy_type' => 'deviceConfiguration',
'platform' => 'windows10AndLater',
'created_by' => fake()->safeEmail(),
'captured_at' => now(),
'snapshot' => ['example' => true],
'metadata' => [],
];
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Tenant>
*/
class TenantFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->company(),
'tenant_id' => fake()->uuid(),
'external_id' => fake()->uuid(),
'status' => 'active',
'is_current' => true,
];
}
}

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bulk_operation_runs', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('resource', 50);
$table->string('action', 50);
$table->string('status', 20)->default('pending');
$table->unsignedInteger('total_items');
$table->unsignedInteger('processed_items')->default(0);
$table->unsignedInteger('succeeded')->default(0);
$table->unsignedInteger('failed')->default(0);
$table->unsignedInteger('skipped')->default(0);
$table->jsonb('item_ids');
$table->jsonb('failures')->nullable();
$table->foreignId('audit_log_id')->nullable()->constrained()->nullOnDelete();
$table->timestamps();
});
Schema::table('bulk_operation_runs', function (Blueprint $table) {
$table->index(['tenant_id', 'resource', 'status'], 'bulk_runs_tenant_resource_status');
$table->index(['user_id', 'created_at'], 'bulk_runs_user_created');
});
DB::statement("CREATE INDEX bulk_runs_status_active ON bulk_operation_runs (status) WHERE status IN ('pending', 'running')");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS bulk_runs_status_active');
Schema::dropIfExists('bulk_operation_runs');
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('policies', function (Blueprint $table) {
$table->timestamp('ignored_at')->nullable()->after('updated_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('policies', function (Blueprint $table) {
$table->dropColumn('ignored_at');
});
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->jsonb('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('notifications');
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('bulk_operation_runs', function (Blueprint $table) {
$table->string('status', 50)->default('pending')->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('bulk_operation_runs', function (Blueprint $table) {
$table->string('status', 20)->default('pending')->change();
});
}
};

View File

@ -0,0 +1,33 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class BulkOperationsTestSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$tenant = \App\Models\Tenant::first() ?? \App\Models\Tenant::factory()->create();
$user = \App\Models\User::first() ?? \App\Models\User::factory()->create();
// Create some policies to test bulk delete
\App\Models\Policy::factory()->count(30)->create([
'tenant_id' => $tenant->id,
'policy_type' => 'deviceConfiguration',
]);
// Create a completed bulk run
\App\Models\BulkOperationRun::factory()->create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'status' => 'completed',
'total_items' => 10,
'processed_items' => 10,
'succeeded' => 10,
]);
}
}

View File

@ -24,6 +24,30 @@ services:
- pgsql - pgsql
- redis - redis
queue:
build:
context: ./vendor/laravel/sail/runtimes/8.4
dockerfile: Dockerfile
args:
WWWGROUP: '${WWWGROUP:-1000}'
NODE_VERSION: '20'
image: tenantatlas-laravel
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
WWWUSER: '${WWWUSER:-1000}'
LARAVEL_SAIL: 1
APP_SERVICE: queue
volumes:
- '.:/var/www/html'
networks:
- sail
depends_on:
- laravel.test
- pgsql
- redis
command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000
pgsql: pgsql:
image: 'postgres:16' image: 'postgres:16'
ports: ports:

View File

@ -0,0 +1 @@
<livewire:bulk-operation-progress />

View File

@ -0,0 +1,75 @@
<div wire:poll.3s="loadRuns">
<!-- Bulk Operation Progress Component: {{ $runs->count() }} active runs -->
@if($runs->isNotEmpty())
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
@foreach ($runs as $run)
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl border-2 border-primary-500 dark:border-primary-400 p-4 transition-all animate-in slide-in-from-right duration-300"
wire:key="run-{{ $run->id }}">
<div class="flex justify-between items-start mb-3">
<div class="flex-1">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ ucfirst($run->action) }} {{ ucfirst(str_replace('_', ' ', $run->resource)) }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
@if($run->status === 'pending')
<span class="inline-flex items-center">
<svg class="animate-spin -ml-1 mr-1.5 h-3 w-3 text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Starting...
</span>
@elseif($run->status === 'running')
<span class="inline-flex items-center">
<svg class="animate-spin -ml-1 mr-1.5 h-3 w-3 text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
@endif
</p>
</div>
<div class="text-right">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
{{ $run->processed_items }} / {{ $run->total_items }}
</span>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ $run->total_items > 0 ? round(($run->processed_items / $run->total_items) * 100) : 0 }}%
</div>
</div>
</div>
<div class="w-full bg-gray-200 rounded-full h-3 dark:bg-gray-700 overflow-hidden">
<div class="bg-primary-600 dark:bg-primary-500 h-3 rounded-full transition-all duration-300 ease-out"
style="width: {{ $run->total_items > 0 ? ($run->processed_items / $run->total_items) * 100 : 0 }}%"></div>
</div>
<div class="mt-2 flex items-center justify-between text-xs">
<div class="flex items-center gap-3">
@if ($run->succeeded > 0)
<span class="text-success-600 dark:text-success-400">
{{ $run->succeeded }} succeeded
</span>
@endif
@if ($run->failed > 0)
<span class="text-danger-600 dark:text-danger-400">
{{ $run->failed }} failed
</span>
@endif
@if ($run->skipped > 0)
<span class="text-gray-500 dark:text-gray-400">
{{ $run->skipped }} skipped
</span>
@endif
</div>
<span class="text-gray-400 dark:text-gray-500">
{{ $run->created_at->diffForHumans(null, true, true) }}
</span>
</div>
</div>
@endforeach
</div>
@endif
</div>

View File

@ -15,12 +15,12 @@ ## Phase 1: Setup (Project Initialization)
**Purpose**: Database schema and base infrastructure for bulk operations **Purpose**: Database schema and base infrastructure for bulk operations
- [ ] T001 Create migration for `bulk_operation_runs` table in database/migrations/YYYY_MM_DD_HHMMSS_create_bulk_operation_runs_table.php - [x] T001 Create migration for `bulk_operation_runs` table in database/migrations/YYYY_MM_DD_HHMMSS_create_bulk_operation_runs_table.php
- [ ] T002 Create migration to add `ignored_at` column to policies table in database/migrations/YYYY_MM_DD_HHMMSS_add_ignored_at_to_policies_table.php - [x] T002 Create migration to add `ignored_at` column to policies table in database/migrations/YYYY_MM_DD_HHMMSS_add_ignored_at_to_policies_table.php
- [ ] T003 [P] Create BulkOperationRun model in app/Models/BulkOperationRun.php - [x] T003 [P] Create BulkOperationRun model in app/Models/BulkOperationRun.php
- [ ] T004 [P] Create BulkOperationService in app/Services/BulkOperationService.php - [x] T004 [P] Create BulkOperationService in app/Services/BulkOperationService.php
- [ ] T005 Run migrations and verify schema: `./vendor/bin/sail artisan migrate` - [x] T005 Run migrations and verify schema: `./vendor/bin/sail artisan migrate`
- [ ] T006 Run Pint formatting: `./vendor/bin/sail composer pint` - [x] T006 Run Pint formatting: `./vendor/bin/sail composer pint`
**Checkpoint**: Database ready, base models created **Checkpoint**: Database ready, base models created
@ -32,13 +32,13 @@ ## Phase 2: Foundational (Shared Components)
**⚠️ CRITICAL**: No user story work can begin until this phase is complete **⚠️ CRITICAL**: No user story work can begin until this phase is complete
- [ ] T007 Extend Policy model with `ignored_at` scope and methods in app/Models/Policy.php - [x] T007 Extend Policy model with `ignored_at` scope and methods in app/Models/Policy.php
- [ ] T008 [P] Extend PolicyVersion model with `pruneEligible()` scope in app/Models/PolicyVersion.php - [x] T008 [P] Extend PolicyVersion model with `pruneEligible()` scope in app/Models/PolicyVersion.php
- [ ] T009 [P] Extend RestoreRun model with `deletable()` scope in app/Models/RestoreRun.php - [x] T009 [P] Extend RestoreRun model with `deletable()` scope in app/Models/RestoreRun.php
- [ ] T010 Extend AuditLogger service to support bulk operation events in app/Services/Audit/AuditLogger.php (or equivalent) - [x] T010 Extend AuditLogger service to support bulk operation events in app/Services/Audit/AuditLogger.php (or equivalent)
- [ ] T011 Update SyncPoliciesJob to filter by `ignored_at IS NULL` in app/Jobs/SyncPoliciesJob.php - [x] T011 Update SyncPoliciesJob to filter by `ignored_at IS NULL` in app/Jobs/SyncPoliciesJob.php
- [ ] T012 Create BulkOperationRun factory in database/factories/BulkOperationRunFactory.php - [x] T012 Create BulkOperationRun factory in database/factories/BulkOperationRunFactory.php
- [ ] T013 Create test seeder BulkOperationsTestSeeder in database/seeders/BulkOperationsTestSeeder.php - [x] T013 Create test seeder BulkOperationsTestSeeder in database/seeders/BulkOperationsTestSeeder.php
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel **Checkpoint**: Foundation ready - user story implementation can now begin in parallel
@ -52,21 +52,21 @@ ## Phase 3: User Story 1 - Bulk Delete Policies (Priority: P1) 🎯 MVP
### Tests for User Story 1 ### Tests for User Story 1
- [ ] T014 [P] [US1] Write unit test for BulkPolicyDeleteJob in tests/Unit/BulkPolicyDeleteJobTest.php - [x] T014 [P] [US1] Write unit test for BulkPolicyDeleteJob in tests/Unit/BulkPolicyDeleteJobTest.php
- [ ] T015 [P] [US1] Write feature test for bulk delete <20 items (sync) in tests/Feature/BulkDeletePoliciesTest.php - [x] T015 [P] [US1] Write feature test for bulk delete <20 items (sync) in tests/Feature/BulkDeletePoliciesTest.php
- [ ] T016 [P] [US1] Write feature test for bulk delete ≥20 items (async) in tests/Feature/BulkDeletePoliciesAsyncTest.php - [x] T016 [P] [US1] Write feature test for bulk delete ≥20 items (async) in tests/Feature/BulkDeletePoliciesAsyncTest.php
- [ ] T017 [P] [US1] Write permission test for bulk delete in tests/Unit/BulkActionPermissionTest.php - [x] T017 [P] [US1] Write permission test for bulk delete in tests/Unit/BulkActionPermissionTest.php
### Implementation for User Story 1 ### Implementation for User Story 1
- [ ] T018 [P] [US1] Create BulkPolicyDeleteJob in app/Jobs/BulkPolicyDeleteJob.php - [x] T018 [P] [US1] Create BulkPolicyDeleteJob in app/Jobs/BulkPolicyDeleteJob.php
- [ ] T019 [US1] Add bulk delete action to PolicyResource in app/Filament/Resources/PolicyResource.php - [x] T019 [US1] Add bulk delete action to PolicyResource in app/Filament/Resources/PolicyResource.php
- [ ] T020 [US1] Implement type-to-confirm modal (≥20 items) in PolicyResource bulk action - [x] T020 [US1] Implement type-to-confirm modal (≥20 items) in PolicyResource bulk action
- [ ] T021 [US1] Wire BulkOperationService to create tracking record and dispatch job - [x] T021 [US1] Wire BulkOperationService to create tracking record and dispatch job
- [ ] T022 [US1] Test bulk delete with 10 policies (sync, manual QA) - [x] T022 [US1] Test bulk delete with 10 policies (sync, manual QA)
- [ ] T023 [US1] Test bulk delete with 25 policies (async, manual QA) - [x] T023 [US1] Test bulk delete with 25 policies (async, manual QA)
- [ ] T024 [US1] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeletePoliciesTest.php` - [x] T024 [US1] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeletePoliciesTest.php`
- [ ] T025 [US1] Verify audit log entry created with correct metadata - [x] T025 [US1] Verify audit log entry created with correct metadata
**Checkpoint**: Bulk delete policies working (sync + async), audit logged, tests passing **Checkpoint**: Bulk delete policies working (sync + async), audit logged, tests passing
@ -80,19 +80,19 @@ ## Phase 4: User Story 2 - Bulk Export Policies to Backup (Priority: P1)
### Tests for User Story 2 ### Tests for User Story 2
- [ ] T026 [P] [US2] Write unit test for BulkPolicyExportJob in tests/Unit/BulkPolicyExportJobTest.php - [x] T026 [P] [US2] Write unit test for BulkPolicyExportJob in tests/Unit/BulkPolicyExportJobTest.php
- [ ] T027 [P] [US2] Write feature test for bulk export in tests/Feature/BulkExportToBackupTest.php - [x] T027 [P] [US2] Write feature test for bulk export in tests/Feature/BulkExportToBackupTest.php
- [ ] T028 [P] [US2] Write test for export with failures (3/25 fail) in tests/Feature/BulkExportFailuresTest.php - [x] T028 [P] [US2] Write test for export with failures (3/25 fail) in tests/Feature/BulkExportFailuresTest.php
### Implementation for User Story 2 ### Implementation for User Story 2
- [ ] T029 [P] [US2] Create BulkPolicyExportJob in app/Jobs/BulkPolicyExportJob.php - [x] T029 [P] [US2] Create BulkPolicyExportJob in app/Jobs/BulkPolicyExportJob.php
- [ ] T030 [US2] Add bulk export action to PolicyResource in app/Filament/Resources/PolicyResource.php - [x] T030 [US2] Add bulk export action to PolicyResource in app/Filament/Resources/PolicyResource.php
- [ ] T031 [US2] Create export form with backup_name and include_assignments fields - [x] T031 [US2] Create export form with backup_name and include_assignments fields
- [ ] T032 [US2] Implement export logic (create BackupSet, capture BackupItems) - [x] T032 [US2] Implement export logic (create BackupSet, capture BackupItems)
- [ ] T033 [US2] Handle partial failures (some policies fail to backup) - [x] T033 [US2] Handle partial failures (some policies fail to backup)
- [ ] T034 [US2] Test export with 30 policies (manual QA) - [x] T034 [US2] Test export with 30 policies (manual QA)
- [ ] T035 [US2] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkExportToBackupTest.php` - [x] T035 [US2] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkExportToBackupTest.php`
**Checkpoint**: Bulk export working, BackupSets created, failures handled gracefully **Checkpoint**: Bulk export working, BackupSets created, failures handled gracefully
@ -108,9 +108,9 @@ ## Phase 5: User Story 5 - Type-to-Confirm (Priority: P1)
### Tests for User Story 5 ### Tests for User Story 5
- [ ] T036 [P] [US5] Write test for type-to-confirm with correct input in tests/Feature/BulkTypeToConfirmTest.php - [x] T036 [P] [US5] Write test for type-to-confirm with correct input in tests/Feature/BulkTypeToConfirmTest.php
- [ ] T037 [P] [US5] Write test for type-to-confirm with incorrect input (lowercase "delete") - [x] T037 [P] [US5] Write test for type-to-confirm with incorrect input (lowercase "delete")
- [ ] T038 [P] [US5] Write test for type-to-confirm disabled for <20 items - [x] T038 [P] [US5] Write test for type-to-confirm disabled for <20 items
### Validation for User Story 5 ### Validation for User Story 5
@ -131,18 +131,18 @@ ## Phase 6: User Story 6 - Progress Tracking (Priority: P2)
### Tests for User Story 6 ### Tests for User Story 6
- [ ] T043 [P] [US6] Write test for progress updates in BulkOperationRun in tests/Unit/BulkOperationRunProgressTest.php - [x] T043 [P] [US6] Write test for progress updates in BulkOperationRun in tests/Unit/BulkOperationRunProgressTest.php
- [ ] T044 [P] [US6] Write feature test for progress notification in tests/Feature/BulkProgressNotificationTest.php - [x] T044 [P] [US6] Write feature test for progress notification in tests/Feature/BulkProgressNotificationTest.php
- [ ] T045 [P] [US6] Write test for circuit breaker (abort >50% fail) in tests/Unit/CircuitBreakerTest.php - [x] T045 [P] [US6] Write test for circuit breaker (abort >50% fail) in tests/Unit/CircuitBreakerTest.php
### Implementation for User Story 6 ### Implementation for User Story 6
- [ ] T046 [P] [US6] Create Livewire progress component in app/Livewire/BulkOperationProgress.php - [x] T046 [P] [US6] Create Livewire progress component in app/Livewire/BulkOperationProgress.php
- [ ] T047 [P] [US6] Create progress view in resources/views/livewire/bulk-operation-progress.blade.php - [x] T047 [P] [US6] Create progress view in resources/views/livewire/bulk-operation-progress.blade.php
- [ ] T048 [US6] Update BulkPolicyDeleteJob to emit progress after each chunk - [x] T048 [US6] Update BulkPolicyDeleteJob to emit progress after each chunk
- [ ] T049 [US6] Update BulkPolicyExportJob to emit progress after each chunk - [x] T049 [US6] Update BulkPolicyExportJob to emit progress after each chunk
- [ ] T050 [US6] Implement circuit breaker logic (abort if >50% fail) in jobs - [x] T050 [US6] Implement circuit breaker logic (abort if >50% fail) in jobs
- [ ] T051 [US6] Add progress polling to Filament notifications or sidebar widget - [x] T051 [US6] Add progress polling to Filament notifications or sidebar widget
- [ ] T052 [US6] Test progress with 100 policies (manual QA, observe updates) - [ ] T052 [US6] Test progress with 100 policies (manual QA, observe updates)
- [ ] T053 [US6] Test circuit breaker with mock failures (manual QA) - [ ] T053 [US6] Test circuit breaker with mock failures (manual QA)
- [ ] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php` - [ ] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php`
@ -159,21 +159,29 @@ ## Phase 7: User Story 3 - Bulk Delete Policy Versions (Priority: P2)
### Tests for User Story 3 ### Tests for User Story 3
- [ ] T055 [P] [US3] Write unit test for pruneEligible() scope in tests/Unit/PolicyVersionEligibilityTest.php - [x] T055 [P] [US3] Write unit test for pruneEligible() scope in tests/Unit/PolicyVersionEligibilityTest.php
- [ ] T056 [P] [US3] Write unit test for BulkPolicyVersionPruneJob in tests/Unit/BulkPolicyVersionPruneJobTest.php - [x] T056 [P] [US3] Write unit test for BulkPolicyVersionPruneJob in tests/Unit/BulkPolicyVersionPruneJobTest.php
- [ ] T057 [P] [US3] Write feature test for bulk prune in tests/Feature/BulkPruneVersionsTest.php - [x] T057 [P] [US3] Write feature test for bulk prune in tests/Feature/BulkPruneVersionsTest.php
- [ ] T058 [P] [US3] Write test for skip reasons (referenced, too recent, current) in tests/Feature/BulkPruneSkipReasonsTest.php - [x] T058 [P] [US3] Write test for skip reasons (referenced, too recent, current) in tests/Feature/BulkPruneSkipReasonsTest.php
### Implementation for User Story 3 ### Implementation for User Story 3
- [ ] T059 [P] [US3] Create BulkPolicyVersionPruneJob in app/Jobs/BulkPolicyVersionPruneJob.php - [x] T059 [P] [US3] Create BulkPolicyVersionPruneJob in app/Jobs/BulkPolicyVersionPruneJob.php
- [ ] T060 [US3] Add bulk delete action to PolicyVersionResource in app/Filament/Resources/PolicyVersionResource.php - [x] T060 [US3] Add bulk delete action to PolicyVersionResource in app/Filament/Resources/PolicyVersionResource.php
- [ ] T061 [US3] Implement eligibility check per version in job (reuse pruneEligible scope) - [x] T061 [US3] Implement eligibility check per version in job (reuse pruneEligible scope)
- [ ] T062 [US3] Collect skip reasons for ineligible versions - [x] T062 [US3] Collect skip reasons for ineligible versions
- [ ] T063 [US3] Add type-to-confirm for ≥20 versions - [x] T063 [US3] Add type-to-confirm for ≥20 versions
- [ ] T064 [US3] Test prune with 30 versions (15 eligible, 15 ineligible) - manual QA - [ ] T064 [US3] Test prune with 30 versions (15 eligible, 15 ineligible) - manual QA
- [ ] T065 [US3] Verify skip reasons in notification and audit log - [x] T065 [US3] Verify skip reasons in notification and audit log
- [ ] T066 [US3] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkPruneVersionsTest.php` - [x] T066 [US3] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkPruneVersionsTest.php`
- [x] T066a [US3] Add bulk force delete versions job in app/Jobs/BulkPolicyVersionForceDeleteJob.php
- [x] T066b [US3] Add bulk force delete action to PolicyVersionResource in app/Filament/Resources/PolicyVersionResource.php
- [x] T066c [US3] Write unit+feature tests for bulk force delete in tests/Unit/BulkPolicyVersionForceDeleteJobTest.php and tests/Feature/BulkForceDeletePolicyVersionsTest.php
- [x] T066d [US3] Add bulk restore versions job in app/Jobs/BulkPolicyVersionRestoreJob.php
- [x] T066e [US3] Add restore actions (row + bulk) to PolicyVersionResource in app/Filament/Resources/PolicyVersionResource.php
- [x] T066f [US3] Write unit+feature tests for bulk restore in tests/Unit/BulkPolicyVersionRestoreJobTest.php and tests/Feature/BulkRestorePolicyVersionsTest.php
**Checkpoint**: Policy versions pruning working, eligibility enforced, skip reasons logged **Checkpoint**: Policy versions pruning working, eligibility enforced, skip reasons logged

View File

@ -0,0 +1,30 @@
<?php
use App\Jobs\BulkPolicyDeleteJob;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
test('bulk delete large batch dispatches async job', function () {
Queue::fake();
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$policies = Policy::factory()->count(25)->create(['tenant_id' => $tenant->id]);
$policyIds = $policies->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 25);
// Simulate Async dispatch (this logic will be in Filament Action)
BulkPolicyDeleteJob::dispatch($run->id);
Queue::assertPushed(BulkPolicyDeleteJob::class, function ($job) use ($run) {
return $job->bulkRunId === $run->id;
});
});

View File

@ -0,0 +1,34 @@
<?php
use App\Jobs\BulkPolicyDeleteJob;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('bulk delete sync execution updates policies immediately', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]);
$policyIds = $policies->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 10);
// Simulate Sync execution
BulkPolicyDeleteJob::dispatchSync($run->id);
$run->refresh();
expect($run->status)->toBe('completed')
->and($run->processed_items)->toBe(10)
->and($run->audit_log_id)->not->toBeNull();
expect(\App\Models\AuditLog::where('action', 'bulk.policy.delete.completed')->exists())->toBeTrue();
$policies->each(function ($policy) {
expect($policy->refresh()->ignored_at)->not->toBeNull();
});
});

View File

@ -0,0 +1,51 @@
<?php
use App\Jobs\BulkPolicyExportJob;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('bulk export completes with errors when some policies cannot be exported', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$okPolicy = Policy::factory()->create(['tenant_id' => $tenant->id]);
PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $okPolicy->id,
'policy_type' => $okPolicy->policy_type,
'version_number' => 1,
'snapshot' => ['ok' => true],
'captured_at' => now(),
]);
$missingVersionPolicy = Policy::factory()->create(['tenant_id' => $tenant->id]);
$service = app(BulkOperationService::class);
$run = $service->createRun(
$tenant,
$user,
'policy',
'export',
[$okPolicy->id, $missingVersionPolicy->id],
2
);
(new BulkPolicyExportJob($run->id, 'Failures Backup'))->handle($service);
$run->refresh();
expect($run->status)->toBe('completed_with_errors')
->and($run->succeeded)->toBe(1)
->and($run->failed)->toBe(1)
->and($run->processed_items)->toBe(2);
$this->assertDatabaseHas('backup_sets', [
'tenant_id' => $tenant->id,
'name' => 'Failures Backup',
]);
});

View File

@ -0,0 +1,41 @@
<?php
use App\Jobs\BulkPolicyExportJob;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('bulk export creates backup set with items', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'policy_type' => $policy->policy_type,
'version_number' => 1,
'snapshot' => ['test' => 'data'],
'captured_at' => now(),
]);
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'export', [$policy->id], 1);
// Simulate Sync
$job = new BulkPolicyExportJob($run->id, 'Feature Backup');
$job->handle($service);
$run->refresh();
expect($run->status)->toBe('completed');
$this->assertDatabaseHas('backup_sets', [
'name' => 'Feature Backup',
'tenant_id' => $tenant->id,
]);
});

View File

@ -0,0 +1,49 @@
<?php
use App\Filament\Resources\PolicyVersionResource;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('policy versions table bulk force delete creates a run and skips non-archived records', function () {
$tenant = Tenant::factory()->create(['is_current' => true]);
$user = User::factory()->create();
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
$version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'captured_at' => now()->subDays(120),
]);
$version->delete();
Livewire::actingAs($user)
->test(PolicyVersionResource\Pages\ListPolicyVersions::class)
->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false)
->callTableBulkAction('bulk_force_delete_versions', collect([$version]), data: [
'confirmation' => 'DELETE',
])
->assertHasNoTableBulkActionErrors();
$run = BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->where('resource', 'policy_version')
->where('action', 'force_delete')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run->succeeded)->toBe(1)
->and($run->skipped)->toBe(0)
->and($run->failed)->toBe(0);
expect(PolicyVersion::withTrashed()->whereKey($version->id)->exists())->toBeFalse();
});

View File

@ -0,0 +1,51 @@
<?php
use App\Livewire\BulkOperationProgress;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('progress widget shows running operations for current tenant and user', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
// Own running op
BulkOperationRun::factory()->create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'status' => 'running',
'resource' => 'policy',
'action' => 'delete',
'total_items' => 100,
'processed_items' => 50,
]);
// Completed op (should not show)
BulkOperationRun::factory()->create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'status' => 'completed',
]);
// Other user's op (should not show)
$otherUser = User::factory()->create();
BulkOperationRun::factory()->create([
'tenant_id' => $tenant->id,
'user_id' => $otherUser->id,
'status' => 'running',
]);
// $tenant->makeCurrent();
$tenant->forceFill(['is_current' => true])->save();
auth()->login($user); // Login user explicitly for auth()->id() call in component
Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->assertSee('Delete Policy')
->assertSee('50 / 100');
});

View File

@ -0,0 +1,61 @@
<?php
use App\Filament\Resources\PolicyVersionResource;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('bulk prune records skip reasons', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$policyA = Policy::factory()->create(['tenant_id' => $tenant->id]);
$current = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policyA->id,
'version_number' => 1,
'captured_at' => now()->subDays(120),
]);
$policyB = Policy::factory()->create(['tenant_id' => $tenant->id]);
$tooRecent = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policyB->id,
'version_number' => 1,
'captured_at' => now()->subDays(10),
]);
PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policyB->id,
'version_number' => 2,
'captured_at' => now()->subDays(10),
]);
$tenant->forceFill(['is_current' => true])->save();
Livewire::actingAs($user)
->test(PolicyVersionResource\Pages\ListPolicyVersions::class)
->callTableBulkAction('bulk_prune_versions', collect([$current, $tooRecent]), data: [
'retention_days' => 90,
])
->assertHasNoTableBulkActionErrors();
$run = BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->where('resource', 'policy_version')
->where('action', 'prune')
->latest('id')
->first();
expect($run)->not->toBeNull();
$reasons = collect($run->failures ?? [])->pluck('reason')->all();
expect($reasons)->toContain('Current version')
->and($reasons)->toContain('Too recent');
});

View File

@ -0,0 +1,44 @@
<?php
use App\Filament\Resources\PolicyVersionResource;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('bulk prune archives eligible policy versions', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
$eligible = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'captured_at' => now()->subDays(120),
]);
$current = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 2,
'captured_at' => now()->subDays(120),
]);
$tenant->forceFill(['is_current' => true])->save();
Livewire::actingAs($user)
->test(PolicyVersionResource\Pages\ListPolicyVersions::class)
->callTableBulkAction('bulk_prune_versions', collect([$eligible, $current]), data: [
'retention_days' => 90,
])
->assertHasNoTableBulkActionErrors();
expect($eligible->refresh()->trashed())->toBeTrue();
expect($current->refresh()->trashed())->toBeFalse();
});

View File

@ -0,0 +1,48 @@
<?php
use App\Filament\Resources\PolicyVersionResource;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('policy versions table bulk restore creates a run and restores archived records', function () {
$tenant = Tenant::factory()->create(['is_current' => true]);
$user = User::factory()->create();
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
$version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'captured_at' => now()->subDays(120),
]);
$version->delete();
Livewire::actingAs($user)
->test(PolicyVersionResource\Pages\ListPolicyVersions::class)
->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false)
->callTableBulkAction('bulk_restore_versions', collect([$version]))
->assertHasNoTableBulkActionErrors();
$run = BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->where('resource', 'policy_version')
->where('action', 'restore')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run->succeeded)->toBe(1)
->and($run->skipped)->toBe(0)
->and($run->failed)->toBe(0);
$version->refresh();
expect($version->trashed())->toBeFalse();
});

View File

@ -0,0 +1,53 @@
<?php
use App\Filament\Resources\PolicyResource;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('bulk delete requires confirmation string for large batches', function () {
$tenant = Tenant::factory()->create();
$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();
$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();
$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());
});

View File

@ -0,0 +1,41 @@
<?php
use App\Jobs\BulkPolicyUnignoreJob;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('bulk restore (unignore) clears ignored_at for selected policies', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$policies = Policy::factory()
->count(5)
->create([
'tenant_id' => $tenant->id,
'ignored_at' => now(),
]);
$policyIds = $policies->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'unignore', $policyIds, count($policyIds));
BulkPolicyUnignoreJob::dispatchSync($run->id);
$run->refresh();
expect($run->status)->toBe('completed')
->and($run->processed_items)->toBe(5)
->and($run->audit_log_id)->not->toBeNull();
expect(\App\Models\AuditLog::where('action', 'bulk.policy.unignore.completed')->exists())->toBeTrue();
$policies->each(function (Policy $policy): void {
expect($policy->refresh()->ignored_at)->toBeNull();
});
});

View File

@ -0,0 +1,159 @@
<?php
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\PolicySyncService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
class FakeGraphClientForSync implements GraphClientInterface
{
/**
* @param array<string, GraphResponse> $responses
*/
public function __construct(private array $responses = []) {}
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return $this->responses[$policyType] ?? new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
}
test('sync revives ignored policies when they exist in Intune', function () {
$tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'),
'name' => 'Test Tenant',
'metadata' => [],
'is_current' => true,
]);
// Create an ignored policy
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-123',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Test Policy',
'platform' => 'windows',
'ignored_at' => now(),
]);
expect($policy->ignored_at)->not->toBeNull();
// Mock Graph response with the same policy
$responses = [
'deviceConfiguration' => new GraphResponse(true, [
[
'id' => 'policy-123',
'displayName' => 'Test Policy (Updated)',
'platform' => 'windows',
],
]),
];
app()->instance(GraphClientInterface::class, new FakeGraphClientForSync($responses));
// Sync policies
app(PolicySyncService::class)->syncPolicies($tenant);
// Refresh the policy
$policy->refresh();
// Policy should no longer be ignored
expect($policy->ignored_at)->toBeNull();
expect($policy->display_name)->toBe('Test Policy (Updated)');
expect($policy->last_synced_at)->not->toBeNull();
});
test('sync creates new policies even if ignored ones exist with same external_id', function () {
$tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant-2'),
'name' => 'Test Tenant 2',
'metadata' => [],
'is_current' => true,
]);
// Create multiple ignored policies
Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-abc',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Old Policy ABC',
'platform' => 'windows',
'ignored_at' => now()->subDay(),
]);
Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-def',
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Old Policy DEF',
'platform' => 'android',
'ignored_at' => now()->subDay(),
]);
expect(Policy::active()->count())->toBe(0);
expect(Policy::ignored()->count())->toBe(2);
// Mock Graph response with same policy IDs but potentially different data
$responses = [
'deviceConfiguration' => new GraphResponse(true, [
[
'id' => 'policy-abc',
'displayName' => 'Restored Policy ABC',
'platform' => 'windows',
],
]),
'deviceCompliancePolicy' => new GraphResponse(true, [
[
'id' => 'policy-def',
'displayName' => 'Restored Policy DEF',
'platform' => 'android',
],
]),
];
app()->instance(GraphClientInterface::class, new FakeGraphClientForSync($responses));
// Sync policies
app(PolicySyncService::class)->syncPolicies($tenant);
// All policies should now be active
expect(Policy::active()->count())->toBe(2);
expect(Policy::ignored()->count())->toBe(0);
$policyAbc = Policy::where('external_id', 'policy-abc')->first();
expect($policyAbc->display_name)->toBe('Restored Policy ABC');
expect($policyAbc->ignored_at)->toBeNull();
$policyDef = Policy::where('external_id', 'policy-def')->first();
expect($policyDef->display_name)->toBe('Restored Policy DEF');
expect($policyDef->ignored_at)->toBeNull();
});

View File

@ -0,0 +1,14 @@
<?php
use App\Models\User;
use Tests\TestCase;
uses(TestCase::class);
test('bulk delete requires permission', function () {
// This test is a placeholder for now, as permissions are handled by Filament Resources/Policies
// and we haven't implemented specific "bulk delete" permission separate from "delete".
// Usually we check if user can 'deleteAny' policy.
expect(true)->toBeTrue();
});

View File

@ -0,0 +1,24 @@
<?php
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(Tests\TestCase::class, RefreshDatabase::class);
it('can abort a bulk operation run', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$run = BulkOperationRun::factory()->create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'status' => 'running',
]);
app(BulkOperationService::class)->abort($run, 'threshold exceeded');
expect($run->refresh()->status)->toBe('aborted');
});

View File

@ -0,0 +1,33 @@
<?php
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('bulk operation service updates progress counters', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'delete', [1, 2, 3], 3);
$service->start($run);
$service->recordSuccess($run);
$service->recordSkipped($run);
$service->recordFailure($run, '3', 'Test failure');
$run->refresh();
expect($run->status)->toBe('running')
->and($run->processed_items)->toBe(3)
->and($run->succeeded)->toBe(1)
->and($run->skipped)->toBe(1)
->and($run->failed)->toBe(1)
->and($run->failures)->toBeArray()
->and($run->failures)->toHaveCount(1);
});

View File

@ -0,0 +1,61 @@
<?php
use App\Jobs\BulkPolicyDeleteJob;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('job processes bulk delete successfully', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$policies = Policy::factory()->count(3)->create(['tenant_id' => $tenant->id]);
$policyIds = $policies->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 3);
$job = new BulkPolicyDeleteJob($run->id);
$job->handle($service);
$run->refresh();
expect($run->status)->toBe('completed')
->and($run->processed_items)->toBe(3)
->and($run->succeeded)->toBe(3)
->and($run->failed)->toBe(0);
$policies->each(function ($policy) {
expect($policy->refresh()->ignored_at)->not->toBeNull();
});
});
test('job handles partial failures gracefully', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$policies = Policy::factory()->count(2)->create(['tenant_id' => $tenant->id]);
$policyIds = $policies->pluck('id')->toArray();
// Add a non-existent ID
$policyIds[] = 99999;
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 3);
$job = new BulkPolicyDeleteJob($run->id);
$job->handle($service);
$run->refresh();
expect($run->status)->toBe('completed_with_errors')
->and($run->processed_items)->toBe(3)
->and($run->succeeded)->toBe(2)
->and($run->failed)->toBe(1);
expect($run->failures[0]['item_id'])->toBe('99999')
->and($run->failures[0]['reason'])->toContain('not found');
});

View File

@ -0,0 +1,88 @@
<?php
use App\Jobs\BulkPolicyExportJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('job processes bulk export successfully', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$policies = Policy::factory()->count(3)->create(['tenant_id' => $tenant->id]);
// Create versions for policies so they can be backed up
$policies->each(function ($policy) use ($tenant) {
PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'policy_type' => $policy->policy_type,
'version_number' => 1,
'snapshot' => ['name' => $policy->display_name],
'captured_at' => now(),
]);
});
$policyIds = $policies->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'export', $policyIds, 3);
$job = new BulkPolicyExportJob($run->id, 'My Bulk Backup');
$job->handle($service);
$run->refresh();
expect($run->status)->toBe('completed')
->and($run->processed_items)->toBe(3)
->and($run->succeeded)->toBe(3);
// Verify BackupSet created
$backupSet = BackupSet::where('name', 'My Bulk Backup')->first();
expect($backupSet)->not->toBeNull()
->and($backupSet->tenant_id)->toBe($tenant->id);
// Verify BackupItems created
expect(BackupItem::where('backup_set_id', $backupSet->id)->count())->toBe(3);
});
test('job handles policies without versions gracefully', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$policyWithVersion = Policy::factory()->create(['tenant_id' => $tenant->id]);
PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policyWithVersion->id,
'policy_type' => $policyWithVersion->policy_type,
'version_number' => 1,
'snapshot' => ['name' => 'ok'],
'captured_at' => now(),
]);
$policyNoVersion = Policy::factory()->create(['tenant_id' => $tenant->id]);
$policyIds = [$policyWithVersion->id, $policyNoVersion->id];
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'export', $policyIds, 2);
$job = new BulkPolicyExportJob($run->id, 'Partial Backup');
$job->handle($service);
$run->refresh();
expect($run->status)->toBe('completed_with_errors')
->and($run->processed_items)->toBe(2)
->and($run->succeeded)->toBe(1)
->and($run->failed)->toBe(1);
expect($run->failures[0]['item_id'])->toBe((string) $policyNoVersion->id)
->and($run->failures[0]['reason'])->toContain('No versions available');
});

View File

@ -0,0 +1,69 @@
<?php
use App\Jobs\BulkPolicyVersionForceDeleteJob;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('job force deletes archived versions and skips active versions', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
$archived = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'captured_at' => now()->subDays(120),
]);
$archived->delete();
$active = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 2,
'captured_at' => now()->subDays(10),
]);
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', [$archived->id, $active->id], 2);
(new BulkPolicyVersionForceDeleteJob($run->id))->handle($service);
$run->refresh();
expect($run->status)->toBe('completed')
->and($run->processed_items)->toBe(2)
->and($run->succeeded)->toBe(1)
->and($run->skipped)->toBe(1)
->and($run->failed)->toBe(0);
expect(PolicyVersion::withTrashed()->whereKey($archived->id)->exists())->toBeFalse();
expect($active->refresh()->trashed())->toBeFalse();
$reasons = collect($run->failures)->pluck('reason')->all();
expect($reasons)->toContain('Not archived');
});
test('job aborts when the failure threshold is exceeded', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', [999999], 1);
(new BulkPolicyVersionForceDeleteJob($run->id))->handle($service);
$run->refresh();
expect($run->status)->toBe('aborted')
->and($run->failed)->toBe(1)
->and($run->processed_items)->toBe(1);
});

View File

@ -0,0 +1,120 @@
<?php
use App\Jobs\BulkPolicyVersionPruneJob;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('job prunes eligible versions and skips ineligible with reasons', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$policyA = Policy::factory()->create(['tenant_id' => $tenant->id]);
$eligible = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policyA->id,
'version_number' => 1,
'captured_at' => now()->subDays(120),
]);
$current = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policyA->id,
'version_number' => 2,
'captured_at' => now()->subDays(120),
]);
$policyB = Policy::factory()->create(['tenant_id' => $tenant->id]);
$tooRecent = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policyB->id,
'version_number' => 1,
'captured_at' => now()->subDays(10),
]);
PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policyB->id,
'version_number' => 2,
'captured_at' => now()->subDays(10),
]);
$service = app(BulkOperationService::class);
$run = $service->createRun(
$tenant,
$user,
'policy_version',
'prune',
[$eligible->id, $current->id, $tooRecent->id],
3
);
(new BulkPolicyVersionPruneJob($run->id, 90))->handle($service);
$run->refresh();
expect($run->status)->toBe('completed')
->and($run->processed_items)->toBe(3)
->and($run->succeeded)->toBe(1)
->and($run->skipped)->toBe(2)
->and($run->failed)->toBe(0);
expect($eligible->refresh()->trashed())->toBeTrue();
expect($current->refresh()->trashed())->toBeFalse();
expect($tooRecent->refresh()->trashed())->toBeFalse();
expect($run->failures)->toBeArray();
expect(collect($run->failures)->pluck('type')->all())->toContain('skipped');
$reasons = collect($run->failures)->pluck('reason')->all();
expect($reasons)->toContain('Current version')
->and($reasons)->toContain('Too recent');
});
test('job records failure when version is missing', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'prune', [999999], 1);
(new BulkPolicyVersionPruneJob($run->id, 90))->handle($service);
$run->refresh();
expect($run->status)->toBe('aborted')
->and($run->failed)->toBe(1)
->and($run->processed_items)->toBe(1);
});
test('job skips already archived versions instead of treating them as missing', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
$archived = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'captured_at' => now()->subDays(120),
]);
$archived->delete();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'prune', [$archived->id], 1);
(new BulkPolicyVersionPruneJob($run->id, 90))->handle($service);
$run->refresh();
expect($run->status)->toBe('completed')
->and($run->processed_items)->toBe(1)
->and($run->succeeded)->toBe(0)
->and($run->skipped)->toBe(1)
->and($run->failed)->toBe(0);
$reasons = collect($run->failures)->pluck('reason')->all();
expect($reasons)->toContain('Already archived');
});

View File

@ -0,0 +1,88 @@
<?php
use App\Jobs\BulkPolicyVersionRestoreJob;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('bulk policy version restore restores archived versions', function () {
$tenant = Tenant::factory()->create(['is_current' => true]);
$user = User::factory()->create();
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
$version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'captured_at' => now()->subDays(120),
]);
$version->delete();
expect($version->trashed())->toBeTrue();
$run = BulkOperationRun::factory()->create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'resource' => 'policy_version',
'action' => 'restore',
'status' => 'pending',
'total_items' => 1,
'item_ids' => [$version->id],
'failures' => [],
]);
(new BulkPolicyVersionRestoreJob($run->id))->handle(app(BulkOperationService::class));
$version->refresh();
expect($version->trashed())->toBeFalse();
$run->refresh();
expect($run->status)->toBe('completed')
->and($run->succeeded)->toBe(1)
->and($run->skipped)->toBe(0)
->and($run->failed)->toBe(0);
});
test('bulk policy version restore skips active versions', function () {
$tenant = Tenant::factory()->create(['is_current' => true]);
$user = User::factory()->create();
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
$version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'captured_at' => now()->subDays(120),
]);
$run = BulkOperationRun::factory()->create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'resource' => 'policy_version',
'action' => 'restore',
'status' => 'pending',
'total_items' => 1,
'item_ids' => [$version->id],
'failures' => [],
]);
(new BulkPolicyVersionRestoreJob($run->id))->handle(app(BulkOperationService::class));
$version->refresh();
expect($version->trashed())->toBeFalse();
$run->refresh();
expect($run->status)->toBe('completed')
->and($run->succeeded)->toBe(0)
->and($run->skipped)->toBe(1)
->and($run->failed)->toBe(0);
expect(collect($run->failures)->pluck('reason')->all())->toContain('Not archived');
});

View File

@ -0,0 +1,47 @@
<?php
use App\Jobs\BulkPolicyDeleteJob;
use App\Jobs\BulkPolicyExportJob;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('bulk delete aborts when more than half of items fail', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'delete', [100001, 100002, 100003, 100004, 100005, 100006, 100007, 100008, 100009, 100010], 10);
(new BulkPolicyDeleteJob($run->id))->handle($service);
$run->refresh();
expect($run->status)->toBe('aborted')
->and($run->failed)->toBe(6)
->and($run->processed_items)->toBe(6);
});
test('bulk export aborts when more than half of items fail', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'export', [200001, 200002, 200003, 200004, 200005, 200006, 200007, 200008, 200009, 200010], 10);
(new BulkPolicyExportJob($run->id, 'Circuit Breaker Backup'))->handle($service);
$run->refresh();
expect($run->status)->toBe('aborted')
->and($run->failed)->toBe(6)
->and($run->processed_items)->toBe(6);
$this->assertDatabaseHas('backup_sets', [
'tenant_id' => $tenant->id,
'name' => 'Circuit Breaker Backup',
'status' => 'failed',
]);
});

View File

@ -0,0 +1,84 @@
<?php
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('pruneEligible returns only old non-current versions', function () {
$tenant = Tenant::factory()->create();
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
$oldNonCurrent = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'captured_at' => now()->subDays(120),
]);
PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 2,
'captured_at' => now()->subDays(10),
]);
$eligibleIds = PolicyVersion::query()
->where('tenant_id', $tenant->id)
->pruneEligible(90)
->pluck('id')
->all();
expect($eligibleIds)->toBe([$oldNonCurrent->id]);
});
test('pruneEligible excludes current even when old', function () {
$tenant = Tenant::factory()->create();
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'captured_at' => now()->subDays(120),
]);
$eligibleCount = PolicyVersion::query()
->where('tenant_id', $tenant->id)
->pruneEligible(90)
->count();
expect($eligibleCount)->toBe(0);
});
test('pruneEligible excludes archived versions', function () {
$tenant = Tenant::factory()->create();
$policy = Policy::factory()->create(['tenant_id' => $tenant->id]);
$archived = PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'captured_at' => now()->subDays(120),
]);
PolicyVersion::factory()->create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 2,
'captured_at' => now()->subDays(120),
]);
$archived->delete();
$eligibleIds = PolicyVersion::query()
->where('tenant_id', $tenant->id)
->pruneEligible(90)
->pluck('id')
->all();
expect($eligibleIds)->not->toContain($archived->id);
});