Feature 004 (Assignments & Scope Tags): - Use fallback strategy for assignments read (direct + $expand) - Use POST /directoryObjects/getByIds for stable group resolution - POST /assign only (not PATCH) for assignments write - Handle 204 No Content responses Feature 005 (Bulk Operations): - Policies: Local delete only (ignored_at flag, no Graph DELETE) - Policy Versions: Eligibility checks + retention policy - BulkOperationRun model for progress tracking - Livewire polling for UI updates (not automatic) - Chunked processing + circuit breaker (abort >50% fail) - array $ids in Job constructor (not Collection)
22 KiB
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:
-
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."
-
Given I confirm the bulk delete, When the operation completes, Then:
- All 15 policies are flagged (
ignored_attimestamp set, optionallydeleted_at) - A success notification shows: "Deleted 15 policies locally"
- An audit log entry
policies.bulk_deleted_localis created with policy IDs - Policies remain in Intune (unchanged)
- All 15 policies are flagged (
-
Given I bulk-delete 50 policies, When the operation runs, Then it processes asynchronously via queue (job) with progress notification
-
Given I lack
policies.deletepermission, 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:
-
Given I select 25 policies, When I click "Export to Backup", Then a dialog prompts: "Backup Set Name" + "Include Assignments?" checkbox
-
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)"
-
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:
-
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."
-
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_prunedwith version IDs + skip reasons
-
Given I lack
policy_versions.prunepermission, 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:
-
Given I select 20 restore runs (status: completed/failed), When I click "Delete", Then confirmation: "Delete 20 restore runs? Historical data will be removed."
-
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
-
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:
-
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
-
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"
-
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:
-
Given I bulk-delete 100 policies, When the job starts, Then a Filament notification shows: "Deleting policies... 0/100"
-
Given the job processes items, When progress updates, Then the notification updates every 5 seconds: "Deleting... 45/100"
-
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:
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:
BulkOperationRunmodel (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 = falseANDcreated_at < NOW() - 90 daysAND NOT referenced - Referenced = exists in
backup_items.policy_version_idORrestore_runs.metadataOR 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
// 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
// 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
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 outcomesBulkActionPermissionTest: Verify permission checksConfirmationDialogTest: Test type-to-confirm logic
Feature Tests
BulkDeletePoliciesTest: E2E flow (select → confirm → verify soft delete)BulkExportToBackupTest: E2E export with job queueBulkProgressNotificationTest: 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
- ✅ Bulk delete 100 policies in <2 minutes (queued)
- ✅ Type-to-confirm prevents accidental deletes
- ✅ Progress notifications update every 5-10s
- ✅ Audit log captures per-item outcomes
- ✅ 95%+ success rate for bulk operations
- ✅ Tests cover all P1/P2 actions
Open Questions
- Should we add bulk "Tag" (apply labels/categories)?
- Bulk "Clone" for policies (create duplicates)?
- Max items per bulk operation (hard limit)?
- Retry failed items in bulk operation?
Status: Draft for Review
Created: 2025-12-22
Author: AI + Ahmed
Next Steps: Review → Plan → Tasks