# Feature 005: Bulk Operations for Resource Management ## Overview Enable efficient bulk operations across TenantPilot's main resources (Policies, Policy Versions, Backup Sets, Restore Runs) to improve admin productivity and reduce repetitive actions. ## Problem Statement Currently, admins must perform actions one-by-one on individual resources: - Deleting 20 old Policy Versions = 20 clicks + confirmations - Exporting 50 Policies to a Backup = 50 manual selections - Cleaning up 30 failed Restore Runs = 30 delete actions **This is tedious, error-prone, and time-consuming.** **With bulk operations:** - Select multiple items → single action → confirm → done - Clear audit trail (one bulk action = one audit event + per-item outcomes) - Progress notifications for long-running operations - Consistent UX across all resources ## Goals - **Primary**: Implement bulk delete, bulk export, bulk restore (soft delete) for main resources - **Secondary**: Safety gates (confirmation dialogs, type-to-confirm for destructive ops) - **Tertiary**: Queue-based processing for large batches with progress tracking - **Non-Goal**: Bulk edit/update (too complex, deferred to future feature) --- ## User Stories ### User Story 1 - Bulk Delete Policies (Priority: P1) **As an admin**, I want to soft-delete multiple policies **locally in TenantPilot** at once, so I can clean up outdated or test policies efficiently. **Important**: This action marks policies as deleted locally, does NOT delete them in Intune. Policies are flagged as `ignored_at` to prevent re-sync. **Acceptance Criteria:** 1. **Given** I select 15 policies in the Policies table, **When** I click "Delete (Local)" in the bulk actions menu, **Then** a confirmation dialog appears: "Delete 15 policies locally? They will be hidden from listings and ignored in sync." 2. **Given** I confirm the bulk delete, **When** the operation completes, **Then**: - All 15 policies are flagged (`ignored_at` timestamp set, optionally `deleted_at`) - A success notification shows: "Deleted 15 policies locally" - An audit log entry `policies.bulk_deleted_local` is created with policy IDs - Policies remain in Intune (unchanged) 3. **Given** I bulk-delete 50 policies, **When** the operation runs, **Then** it processes asynchronously via queue (job) with progress notification 4. **Given** I lack `policies.delete` permission, **When** I try to bulk-delete, **Then** the bulk action is disabled/hidden (same permission model as single delete) --- ### User Story 2 - Bulk Export Policies to Backup (Priority: P1) **As an admin**, I want to export multiple policies to a new Backup Set in one action, so I can quickly snapshot a subset of policies. **Acceptance Criteria:** 1. **Given** I select 25 policies, **When** I click "Export to Backup", **Then** a dialog prompts: "Backup Set Name" + "Include Assignments?" checkbox 2. **Given** I confirm the export, **When** the backup job runs, **Then**: - A new Backup Set is created - 25 Backup Items are captured (one per policy) - Progress notification: "Backing up... 10/25" - Final notification: "Backup Set 'Production Snapshot' created (25 items)" 3. **Given** 3 of 25 policies fail to backup (Graph error), **When** the job completes, **Then**: - 22 items succeed, 3 fail - Notification: "Backup completed: 22 succeeded, 3 failed" - Audit log records per-item outcomes --- ### User Story 3 - Bulk Delete Policy Versions (Priority: P2) **As an admin**, I want to bulk-delete old policy versions to free up database space, respecting retention policies. **Important**: Policy Versions are immutable snapshots. Deletion only allowed if version is NOT referenced (no active Backup Items, Restore Runs, or audit trails) and meets retention threshold (e.g., >90 days old). **Acceptance Criteria:** 1. **Given** I select 30 policy versions older than 90 days, **When** I click "Delete", **Then** confirmation dialog: "Delete 30 policy versions? This is permanent and cannot be undone." 2. **Given** I confirm, **When** the operation completes, **Then**: - System checks each version: is_current=false + not referenced + age >90 days - Eligible versions are hard-deleted - Ineligible versions are skipped with reason (e.g., "Referenced by Backup Set ID 5") - Success notification: "Deleted 28 policy versions (2 skipped)" - Audit log: `policy_versions.bulk_pruned` with version IDs + skip reasons 3. **Given** I lack `policy_versions.prune` permission, **When** I try to bulk-delete, **Then** the bulk action is hidden --- ### User Story 4 - Bulk Delete Restore Runs (Priority: P2) **As an admin**, I want to bulk-delete completed or failed Restore Runs to declutter the history. **Acceptance Criteria:** 1. **Given** I select 20 restore runs (status: completed/failed), **When** I click "Delete", **Then** confirmation: "Delete 20 restore runs? Historical data will be removed." 2. **Given** I confirm, **When** the operation completes, **Then**: - 20 restore runs are soft-deleted - Notification: "Deleted 20 restore runs" - Audit log: `restore_runs.bulk_deleted` 3. **Given** I select restore runs with mixed statuses (running + completed), **When** I attempt bulk delete, **Then** only completed/failed runs are deleted (running ones skipped with warning) --- ### User Story 5 - Bulk Delete with Type-to-Confirm (Priority: P1) **As an admin**, I want extra confirmation for large destructive operations, so I don't accidentally delete important data. **Acceptance Criteria:** 1. **Given** I bulk-delete ≥20 items, **When** the confirmation dialog appears, **Then** I must type "DELETE" in a text field to enable the confirm button 2. **Given** I type an incorrect word (e.g., "delete" lowercase), **When** I try to confirm, **Then** the button remains disabled with error: "Type DELETE to confirm" 3. **Given** I type "DELETE" correctly, **When** I click confirm, **Then** the bulk operation proceeds --- ### User Story 6 - Bulk Operation Progress Tracking (Priority: P2) **As an admin**, I want to see real-time progress for bulk operations, so I know the system is working. **Acceptance Criteria:** 1. **Given** I bulk-delete 100 policies, **When** the job starts, **Then** a Filament notification shows: "Deleting policies... 0/100" 2. **Given** the job processes items, **When** progress updates, **Then** the notification updates every 5 seconds: "Deleting... 45/100" 3. **Given** the job completes, **When** all items are processed, **Then**: - Final notification: "Deleted 98 policies (2 failed)" - Clickable link: "View details" → opens audit log entry --- ## Functional Requirements ### General Bulk Operations **FR-005.1**: System MUST provide bulk action checkboxes on table rows for: - Policies - Policy Versions - Backup Sets - Restore Runs **FR-005.2**: Bulk actions menu MUST appear when ≥1 item is selected, showing: - Action name (e.g., "Delete") - Count badge (e.g., "3 selected") - Disabled state if user lacks permission **FR-005.3**: System MUST enforce same permissions for bulk actions as single actions (e.g., `policies.delete` for bulk delete). **FR-005.4**: Bulk operations processing ≥20 items MUST run via Laravel Queue (async job) using Bus::batch() or chunked processing (batches of 10-20 items). **FR-005.4a**: System MUST create a `bulk_operation_runs` table to track progress: ```php Schema::create('bulk_operation_runs', function (Blueprint $table) { $table->id(); $table->foreignId('tenant_id')->constrained(); $table->foreignId('user_id')->constrained(); $table->string('resource'); // 'policies', 'policy_versions', etc. $table->string('action'); // 'delete', 'export', etc. $table->string('status'); // 'running', 'completed', 'failed', 'aborted' $table->integer('total_items'); $table->integer('processed_items')->default(0); $table->integer('succeeded')->default(0); $table->integer('failed')->default(0); $table->integer('skipped')->default(0); $table->json('item_ids'); // array of IDs $table->json('failures')->nullable(); // [{id, reason}, ...] $table->foreignId('audit_log_id')->nullable()->constrained(); $table->timestamps(); }); **FR-005.5**: Bulk operations <20 items MAY run synchronously (immediate feedback). ### Confirmation Dialogs **FR-005.6**: Confirmation dialog MUST show: - Action description: "Delete 15 policies?" - Impact warning: "This moves them to trash." or "This is permanent." - Item count badge - Cancel/Confirm buttons **FR-005.7**: For destructive operations with ≥20 items, dialog MUST require typing "DELETE" (case-sensitive) to enable confirm button. **FR-005.8**: For non-destructive operations (export, restore), typing confirmation is NOT required. ### Audit Logging **FR-005.9**: System MUST create one audit log entry per bulk operation with: - Event type: `{resource}.bulk_{action}` (e.g., `policies.bulk_deleted`) - Actor (user ID/email) - Metadata: `{ item_count: 15, item_ids: [...], outcomes: {...} }` **FR-005.10**: Audit log MUST record per-item outcomes: ```json { "item_count": 15, "succeeded": 13, "failed": 2, "skipped": 0, "failures": [ {"id": "abc-123", "reason": "Graph API error: 503"}, {"id": "def-456", "reason": "Policy not found"} ] } ``` ### Progress Tracking **FR-005.11**: For queued bulk jobs (≥20 items), system MUST emit progress via: - `BulkOperationRun` model (status, processed_items updated after each batch) - Livewire polling on UI (every 3-5 seconds) to fetch updated progress - Filament notification with progress bar: - Initial: "Processing... 0/{count}" - Periodic: "Processing... {done}/{count}" - Final: "Completed: {succeeded} succeeded, {failed} failed" **FR-005.11a**: UI MUST poll `BulkOperationRun` status endpoint (e.g., `/api/bulk-operations/{id}/status`) or use Livewire wire:poll to refresh progress. **FR-005.12**: Final notification MUST include link to audit log entry for details. **FR-005.13**: If job fails catastrophically (exception), notification MUST show: "Bulk operation failed. Contact support." ### Error Handling **FR-005.14**: System MUST continue processing remaining items if one fails (fail-soft, not fail-fast). **FR-005.15**: System MUST collect all failures and report them in final notification + audit log. **FR-005.16**: If >50% of items fail, system MUST: - Abort processing remaining items (status = `aborted`) - Final notification: "Bulk operation aborted: {failed}/{total} failures exceeded threshold" - Admin can manually trigger "Retry Failed Items" from BulkOperationRun detail view (future enhancement) --- ## Bulk Actions by Resource ### Policies Resource | Action | Priority | Destructive | Scope | Threshold for Queue | Type-to-Confirm | |--------|----------|-------------|-------|---------------------|-----------------| | Delete (local) | P1 | Yes (local only) | TenantPilot DB | ≥20 | ≥20 | | Export to Backup | P1 | No | TenantPilot DB | ≥20 | No | | Force Delete | P3 | Yes (local) | TenantPilot DB | ≥10 | Always | | Restore (untrash) | P3 | No | TenantPilot DB | ≥50 | No | | Sync (re-fetch) | P4 | No | Graph read | ≥50 | No | **FR-005.17**: Bulk Delete for Policies MUST set `ignored_at` timestamp (prevents re-sync) + optionally `deleted_at` (soft delete). Does NOT call Graph DELETE. **FR-005.17a**: Sync Job MUST skip policies where `ignored_at IS NOT NULL`. **FR-005.18**: Bulk Export to Backup MUST prompt for: - Backup Set name (auto-generated default: "Bulk Export {date}") - "Include Assignments" checkbox (if Feature 004 implemented) **FR-005.19**: Bulk Sync MUST queue a SyncPoliciesJob for each selected policy. ### Policy Versions Resource | Action | Priority | Destructive | Threshold for Queue | Type-to-Confirm | |--------|----------|-------------|---------------------|-----------------| | Delete | P2 | Yes | ≥20 | ≥20 | | Export to Backup | P3 | No | ≥20 | No | **FR-005.20**: Bulk Delete for Policy Versions MUST: - Check eligibility: `is_current = false` AND `created_at < NOW() - 90 days` AND NOT referenced - Referenced = exists in `backup_items.policy_version_id` OR `restore_runs.metadata` OR critical audit logs - Hard-delete eligible versions - Skip ineligible with reason: "Referenced", "Too recent", "Current version" **FR-005.21**: System MUST require `policy_versions.prune` permission (separate from `policy_versions.delete`). ### Backup Sets Resource | Action | Priority | Destructive | Threshold for Queue | Type-to-Confirm | |--------|----------|-------------|---------------------|-----------------| | Delete | P2 | Yes | ≥10 | ≥10 | | Archive (flag) | P3 | No | N/A | No | **FR-005.22**: Bulk Delete for Backup Sets MUST cascade-delete related Backup Items. **FR-005.23**: Bulk Archive MUST set `archived_at` timestamp (soft flag, keeps data). ### Restore Runs Resource | Action | Priority | Destructive | Threshold for Queue | Type-to-Confirm | |--------|----------|-------------|---------------------|-----------------| | Delete | P2 | Yes | ≥20 | ≥20 | | Rerun | P3 | No | N/A | No | | Cancel (abort) | P3 | No | N/A | No | **FR-005.24**: Bulk Delete for Restore Runs MUST soft-delete. **FR-005.25**: Bulk Delete MUST skip runs with status `running` (show warning in results). **FR-005.26**: Bulk Rerun (if T156 implemented) MUST create new RestoreRun for each selected run. --- ## Non-Functional Requirements **NFR-005.1**: Bulk operations MUST handle up to 500 items per operation without timeout. **NFR-005.2**: Queue jobs MUST process items in batches of 10-20 (configurable) to avoid memory issues. **NFR-005.3**: Progress notifications MUST update at least every 10 seconds (avoid spamming). **NFR-005.4**: UI MUST remain responsive during bulk operations (no blocking spinner). **NFR-005.5**: Bulk operations MUST respect tenant isolation (only act on current tenant's data). --- ## Technical Implementation ### Filament Bulk Actions Setup ```php // Example: PolicyResource.php public static function table(Table $table): Table { return $table ->columns([...]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make() ->requiresConfirmation() ->modalHeading(fn (Collection $records) => "Delete {$records->count()} policies?") ->modalDescription('This moves them to trash.') ->action(fn (Collection $records) => BulkPolicyDeleteJob::dispatch($records->pluck('id')) ), Tables\Actions\BulkAction::make('export_to_backup') ->label('Export to Backup') ->icon('heroicon-o-arrow-down-tray') ->form([ Forms\Components\TextInput::make('backup_name') ->default('Bulk Export ' . now()->format('Y-m-d')), Forms\Components\Checkbox::make('include_assignments') ->label('Include Assignments & Scope Tags'), ]) ->action(fn (Collection $records, array $data) => BulkPolicyExportJob::dispatch($records->pluck('id'), $data) ), ]), ]); } ``` ### Queue Job Structure ```php // app/Jobs/BulkPolicyDeleteJob.php class BulkPolicyDeleteJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public function __construct( public array $policyIds, // array, NOT Collection (serialization) public int $tenantId, // explicit tenant isolation public int $actorId, // user ID, not just email public int $bulkOperationRunId // FK to bulk_operation_runs table ) {} public function handle( AuditLogger $audit, PolicyRepository $policies ): void { $run = BulkOperationRun::find($this->bulkOperationRunId); $run->update(['status' => 'running']); $results = ['succeeded' => 0, 'failed' => 0, 'skipped' => 0, 'failures' => []]; // Process in chunks for memory efficiency collect($this->policyIds)->chunk(10)->each(function ($chunk) use (&$results, $policies, $run) { foreach ($chunk as $id) { try { $policies->markIgnored($id); // set ignored_at $results['succeeded']++; } catch (\Exception $e) { $results['failed']++; $results['failures'][] = ['id' => $id, 'reason' => $e->getMessage()]; } } // Update progress after each chunk $run->update([ 'processed_items' => $results['succeeded'] + $results['failed'], 'succeeded' => $results['succeeded'], 'failed' => $results['failed'], 'failures' => $results['failures'], ]); // Circuit breaker: abort if >50% failed if ($results['failed'] > count($this->policyIds) * 0.5) { $run->update(['status' => 'aborted']); throw new \Exception('Bulk operation aborted: >50% failure rate'); } }); $auditLogId = $audit->log('policies.bulk_deleted_local', [ 'item_count' => count($this->policyIds), 'outcomes' => $results, 'bulk_operation_run_id' => $this->bulkOperationRunId, ]); $run->update(['status' => 'completed', 'audit_log_id' => $auditLogId]); } } ``` ### Type-to-Confirm Modal ```php Tables\Actions\DeleteBulkAction::make() ->requiresConfirmation() ->modalHeading(fn (Collection $records) => $records->count() >= 20 ? "⚠️ Delete {$records->count()} policies?" : "Delete {$records->count()} policies?" ) ->form(fn (Collection $records) => $records->count() >= 20 ? [ Forms\Components\TextInput::make('confirm_delete') ->label('Type DELETE to confirm') ->rule('in:DELETE') ->required() ->helperText('This action cannot be undone.') ] : [] ) ``` --- ## UI/UX Patterns ### Bulk Action Menu ``` ┌────────────────────────────────────────────┐ │ ☑ Select All (50 items) │ │ │ │ 15 selected │ │ [Delete] [Export to Backup] [More ▾] │ └────────────────────────────────────────────┘ ``` ### Confirmation Dialog (≥20 items) ``` ⚠️ Delete 25 policies? This moves them to trash. You can restore them later. Type DELETE to confirm: [________________] [Cancel] [Confirm] (disabled until typed) ``` ### Progress Notification ``` 🔄 Deleting policies... ████████████░░░░░░░░ 45 / 100 [View Details] ``` ### Final Notification ``` ✅ Deleted 98 policies 2 items failed (click for details) [View Audit Log] [Dismiss] ``` --- ## Testing Strategy ### Unit Tests - `BulkPolicyDeleteJobTest`: Mock policy repo, test outcomes - `BulkActionPermissionTest`: Verify permission checks - `ConfirmationDialogTest`: Test type-to-confirm logic ### Feature Tests - `BulkDeletePoliciesTest`: E2E flow (select → confirm → verify soft delete) - `BulkExportToBackupTest`: E2E export with job queue - `BulkProgressNotificationTest`: Verify progress events emitted ### Load Tests - 500 items bulk delete (should complete in <5 minutes) - 1000 items bulk export (queue + batch processing) ### Manual QA - Select 30 policies → bulk delete → verify trash - Export 50 policies → verify backup set created - Test type-to-confirm with correct/incorrect input - Force job failure → verify error handling --- ## Rollout Plan ### Phase 1: Foundation (P1 Actions) - Policies: Bulk Delete, Bulk Export - Confirmation dialogs + type-to-confirm - **Duration**: ~8-12 hours ### Phase 2: Queue + Progress (P1 Features) - Queue jobs for ≥20 items - Progress notifications - Audit logging - **Duration**: ~8-10 hours ### Phase 3: Additional Resources (P2 Actions) - Policy Versions: Bulk Delete - Restore Runs: Bulk Delete - Backup Sets: Bulk Delete - **Duration**: ~6-8 hours ### Phase 4: Advanced Actions (P3 Optional) - Bulk Force Delete - Bulk Restore (untrash) - Bulk Rerun (depends on T156) - **Duration**: ~4-6 hours per action --- ## Dependencies - Laravel Queue (✅ configured) - Filament Bulk Actions (✅ built-in) - Feature 001: Audit Logger (✅ complete) ## Risks & Mitigations | Risk | Mitigation | |------|------------| | Large batches cause timeout | Queue jobs + chunked processing (10-20 items/batch) + Bus::batch() | | User accidentally deletes 500 items | Type-to-confirm for ≥20 items + `ignored_at` flag (restorable) | | Job fails mid-process | Fail-soft, log failures in `bulk_operation_runs`, abort if >50% fail | | UI becomes unresponsive | Async jobs + Livewire polling for progress | | Policy Versions deleted while referenced | Eligibility check: not referenced in backups/restores/audits | | Sync re-adds "deleted" policies | `ignored_at` flag prevents re-sync | | Progress notifications don't update | `BulkOperationRun` model + polling required (not automatic Filament feature) | --- ## Success Criteria 1. ✅ Bulk delete 100 policies in <2 minutes (queued) 2. ✅ Type-to-confirm prevents accidental deletes 3. ✅ Progress notifications update every 5-10s 4. ✅ Audit log captures per-item outcomes 5. ✅ 95%+ success rate for bulk operations 6. ✅ Tests cover all P1/P2 actions --- ## Open Questions 1. Should we add bulk "Tag" (apply labels/categories)? 2. Bulk "Clone" for policies (create duplicates)? 3. Max items per bulk operation (hard limit)? 4. Retry failed items in bulk operation? --- **Status**: Draft for Review **Created**: 2025-12-22 **Author**: AI + Ahmed **Next Steps**: Review → Plan → Tasks