From 388a7b889d9f62aa35fd3a3f5f420b32c22c3cbd Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 22 Dec 2025 00:18:58 +0100 Subject: [PATCH 01/21] chore: remove duplicate feature 185-settings-catalog-readable --- .../IMPLEMENTATION_STATUS.md | 469 ----------------- .../MANUAL_VERIFICATION_GUIDE.md | 312 ------------ specs/185-settings-catalog-readable/plan.md | 414 --------------- specs/185-settings-catalog-readable/spec.md | 240 --------- specs/185-settings-catalog-readable/tasks.md | 472 ------------------ 5 files changed, 1907 deletions(-) delete mode 100644 specs/185-settings-catalog-readable/IMPLEMENTATION_STATUS.md delete mode 100644 specs/185-settings-catalog-readable/MANUAL_VERIFICATION_GUIDE.md delete mode 100644 specs/185-settings-catalog-readable/plan.md delete mode 100644 specs/185-settings-catalog-readable/spec.md delete mode 100644 specs/185-settings-catalog-readable/tasks.md diff --git a/specs/185-settings-catalog-readable/IMPLEMENTATION_STATUS.md b/specs/185-settings-catalog-readable/IMPLEMENTATION_STATUS.md deleted file mode 100644 index ce52f7d..0000000 --- a/specs/185-settings-catalog-readable/IMPLEMENTATION_STATUS.md +++ /dev/null @@ -1,469 +0,0 @@ -# Feature 185: Implementation Status Report - -## Executive Summary - -**Status**: ✅ **Core Implementation Complete** (Phases 1-5) -**Date**: 2025-12-13 -**Remaining Work**: Testing & Manual Verification (Phases 6-7) - -## Implementation Progress - -### ✅ Completed Phases (1-5) - -#### Phase 1: Database Foundation -- ✅ T001: Migration created and applied successfully (73.61ms) -- ✅ T002: SettingsCatalogDefinition model with helper methods -- **Result**: `settings_catalog_definitions` table exists with GIN index on JSONB - -#### Phase 2: Definition Resolver Service -- ✅ T003-T007: Complete SettingsCatalogDefinitionResolver service -- **Features**: - - 3-tier caching: Memory → Database (30 days) → Graph API - - Batch resolution with `$filter=id in (...)` optimization - - Non-blocking cache warming with error handling - - Graceful fallback with prettified definition IDs -- **File**: `app/Services/Intune/SettingsCatalogDefinitionResolver.php` (267 lines) - -#### Phase 3: Snapshot Enrichment -- ✅ T008-T010: Extended PolicySnapshotService -- **Features**: - - Extracts definition IDs from settings (including nested children) - - Calls warmCache() after settings hydration - - Adds metadata: `definition_count`, `definitions_cached` -- **File**: `app/Services/Intune/PolicySnapshotService.php` (extended) - -#### Phase 4: Normalizer Enhancement -- ✅ T011-T014: Extended PolicyNormalizer -- **Features**: - - `normalizeSettingsCatalogGrouped()` main method - - Value formatting: bool → badges, int → formatted, string → truncated - - Grouping by categoryId with fallback to definition ID segments - - Recursive flattening of nested group settings - - Alphabetical sorting of groups -- **File**: `app/Services/Intune/PolicyNormalizer.php` (extended with 8 new methods) - -#### Phase 5: UI Implementation -- ✅ T015-T022: Complete Settings tab with grouped accordion view -- **Features**: - - Filament Section components for collapsible groups - - First group expanded by default - - Setting rows with labels, formatted values, help text - - Alpine.js copy buttons with clipboard API - - Client-side search filtering - - Empty states and fallback warnings - - Dark mode support -- **Files**: - - `resources/views/filament/infolists/entries/settings-catalog-grouped.blade.php` (~130 lines) - - `app/Filament/Resources/PolicyResource.php` (Settings tab extended) - -### ⏳ Pending Phases (6-7) - -#### Phase 6: Manual Verification (T023-T025) -- [ ] T023: Verify JSON tab still works -- [ ] T024: Verify fallback message for uncached definitions -- [ ] T025: Ensure JSON viewer scoped to Policy View only - -**Estimated Time**: ~15 minutes -**Action Required**: Navigate to `/admin/policies/{id}` for Settings Catalog policy - -#### Phase 7: Testing & Validation (T026-T042) -- [ ] T026-T031: Unit tests (SettingsCatalogDefinitionResolverTest, PolicyNormalizerSettingsCatalogTest) -- [ ] T032-T037: Feature tests (PolicyViewSettingsCatalogReadableTest) -- [ ] T038-T039: Pest suite execution, Pint formatting -- [ ] T040-T042: Git review, migration check, manual QA walkthrough - -**Estimated Time**: ~4-5 hours -**Action Required**: Write comprehensive test coverage - ---- - -## Code Quality Verification - -### ✅ Laravel Pint -- **Status**: PASS - 32 files formatted -- **Command**: `./vendor/bin/sail pint --dirty` -- **Result**: All code compliant with Laravel coding standards - -### ✅ Cache Management -- **Command**: `./vendor/bin/sail artisan optimize:clear` -- **Result**: All caches cleared (config, views, routes, Blade, Filament) - -### ✅ Database Migration -- **Command**: `./vendor/bin/sail artisan migrate` -- **Result**: `settings_catalog_definitions` table exists -- **Verification**: `Schema::hasTable('settings_catalog_definitions')` returns `true` - ---- - -## Architecture Overview - -### Service Layer - -``` -PolicySnapshotService - ↓ (extracts definition IDs) -SettingsCatalogDefinitionResolver - ↓ (resolves definitions) -PolicyNormalizer - ↓ (groups & formats) -PolicyResource (Filament) - ↓ (renders) -settings-catalog-grouped.blade.php -``` - -### Caching Strategy - -``` -Request - ↓ -Memory Cache (Laravel Cache, request-level) - ↓ (miss) -Database Cache (30 days TTL) - ↓ (miss) -Graph API (/deviceManagement/configurationSettings) - ↓ (store) -Database + Memory - ↓ (fallback on Graph failure) -Prettified Definition ID -``` - -### UI Flow - -``` -Policy View (Filament) - ↓ -Tabs: Settings | JSON - ↓ (Settings tab) -Check metadata.definitions_cached - ↓ (true) -settings_grouped ViewEntry - ↓ -normalizeSettingsCatalogGrouped() - ↓ -Blade Component - ↓ -Accordion Groups (Filament Sections) - ↓ -Setting Rows (label, value, help text, copy button) -``` - ---- - -## Files Created/Modified - -### Created Files (5) - -1. **database/migrations/2025_12_13_212126_create_settings_catalog_definitions_table.php** - - Purpose: Cache setting definitions from Graph API - - Schema: 9 columns + timestamps, GIN index on JSONB - - Status: ✅ Applied (73.61ms) - -2. **app/Models/SettingsCatalogDefinition.php** - - Purpose: Eloquent model for cached definitions - - Methods: `findByDefinitionId()`, `findByDefinitionIds()` - - Status: ✅ Complete - -3. **app/Services/Intune/SettingsCatalogDefinitionResolver.php** - - Purpose: Fetch and cache definitions with 3-tier strategy - - Lines: 267 - - Methods: `resolve()`, `resolveOne()`, `warmCache()`, `clearCache()`, `prettifyDefinitionId()` - - Status: ✅ Complete with error handling - -4. **resources/views/filament/infolists/entries/settings-catalog-grouped.blade.php** - - Purpose: Blade template for grouped settings accordion - - Lines: ~130 - - Features: Alpine.js interactivity, Filament Sections, search filtering - - Status: ✅ Complete with dark mode support - -5. **specs/185-settings-catalog-readable/** (Directory with 3 files) - - `spec.md` - Complete feature specification - - `plan.md` - Implementation plan - - `tasks.md` - 42 tasks with FR traceability - - Status: ✅ Complete with implementation notes - -### Modified Files (3) - -1. **app/Services/Intune/PolicySnapshotService.php** - - Changes: Added `SettingsCatalogDefinitionResolver` injection - - New method: `extractDefinitionIds()` (recursive extraction) - - Extended method: `hydrateSettingsCatalog()` (cache warming + metadata) - - Status: ✅ Extended without breaking existing functionality - -2. **app/Services/Intune/PolicyNormalizer.php** - - Changes: Added `SettingsCatalogDefinitionResolver` injection - - New methods: 8 methods (~200 lines) - - `normalizeSettingsCatalogGrouped()` (main entry point) - - `extractAllDefinitionIds()`, `flattenSettingsCatalogForGrouping()` - - `formatSettingsCatalogValue()`, `groupSettingsByCategory()` - - `extractCategoryFromDefinitionId()`, `formatCategoryTitle()` - - Status: ✅ Extended with comprehensive formatting/grouping logic - -3. **app/Filament/Resources/PolicyResource.php** - - Changes: Extended Settings tab in `policy_content` Tabs - - New entries: - - `settings_grouped` ViewEntry (uses Blade component) - - `definitions_not_cached` TextEntry (fallback message) - - Conditional rendering: Grouped view only if `definitions_cached === true` - - Status: ✅ Extended Settings tab, JSON tab preserved - ---- - -## Verification Checklist (Pre-Testing) - -### ✅ Code Quality -- [X] Laravel Pint passed (32 files) -- [X] All code formatted with PSR-12 conventions -- [X] No Pint warnings or errors - -### ✅ Database -- [X] Migration applied successfully -- [X] Table exists with correct schema -- [X] Indexes created (definition_id unique, category_id, GIN on raw) - -### ✅ Service Injection -- [X] SettingsCatalogDefinitionResolver registered in service container -- [X] PolicySnapshotService constructor updated -- [X] PolicyNormalizer constructor updated -- [X] Laravel auto-resolves dependencies - -### ✅ Caching -- [X] All caches cleared (config, views, routes, Blade, Filament) -- [X] Blade component compiled -- [X] Filament schema cache refreshed - -### ✅ UI Integration -- [X] Settings tab extended with grouped view -- [X] JSON tab preserved from Feature 002 -- [X] Conditional rendering based on metadata -- [X] Fallback message implemented - -### ⏳ Manual Verification Pending -- [ ] Navigate to Policy View for Settings Catalog policy -- [ ] Verify accordion renders with groups -- [ ] Verify display names shown (not raw definition IDs) -- [ ] Verify values formatted (badges, numbers, truncated strings) -- [ ] Test search filtering -- [ ] Test copy buttons -- [ ] Switch to JSON tab, verify snapshot renders -- [ ] Test fallback for policy without cached definitions -- [ ] Test dark mode toggle - -### ⏳ Testing Pending -- [ ] Unit tests written and passing -- [ ] Feature tests written and passing -- [ ] Performance benchmarks validated - ---- - -## Next Steps (Priority Order) - -### Immediate (Phase 6 - Manual Verification) - -1. **Open Policy View** (5 min) - - Navigate to `/admin/policies/{id}` for Settings Catalog policy - - Verify page loads without errors - - Check browser console for JavaScript errors - -2. **Verify Tabs & Accordion** (5 min) - - Confirm "Settings" and "JSON" tabs visible - - Click Settings tab, verify accordion renders - - Verify groups collapsible (first expanded by default) - - Click JSON tab, verify snapshot renders with copy button - -3. **Verify Display & Formatting** (5 min) - - Check setting labels show display names (not `device_vendor_msft_...`) - - Verify bool values show as "Enabled"/"Disabled" badges (green/gray) - - Verify int values formatted with separators (e.g., "1,000") - - Verify long strings truncated with "..." and copy button - -4. **Test Search & Fallback** (5 min) - - Type in search box (if visible), verify filtering works - - Test copy buttons (long values) - - Find policy WITHOUT cached definitions - - Verify fallback message: "Definitions not yet cached..." - - Verify JSON tab still accessible - -**Total Estimated Time**: ~20 minutes - -### Short-Term (Phase 7 - Unit Tests) - -1. **Create Unit Tests** (2-3 hours) - - `tests/Unit/SettingsCatalogDefinitionResolverTest.php` - - Test `resolve()` with batch IDs - - Test memory cache hit - - Test database cache hit - - Test Graph API fetch - - Test fallback prettification - - Test non-blocking warmCache() - - `tests/Unit/PolicyNormalizerSettingsCatalogTest.php` - - Test `normalizeSettingsCatalogGrouped()` output structure - - Test value formatting (bool, int, string, choice) - - Test grouping by categoryId - - Test fallback grouping by definition ID segments - - Test recursive definition ID extraction - -2. **Create Feature Tests** (2 hours) - - `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` - - Test Settings Catalog policy view shows tabs - - Test Settings tab shows display names (not definition IDs) - - Test values formatted correctly (badges, numbers, truncation) - - Test search filters settings - - Test fallback message when definitions not cached - - Test JSON tab still accessible - -3. **Run Test Suite** (15 min) - - `./vendor/bin/sail artisan test --filter=SettingsCatalog` - - Fix any failures - - Verify all tests pass - -**Total Estimated Time**: ~5 hours - -### Medium-Term (Performance & Polish) - -1. **Performance Testing** (1 hour) - - Create test policy with 200+ settings - - Measure render time (target: <2s) - - Measure definition resolution time (target: <500ms for 50 cached) - - Profile with Laravel Telescope or Debugbar - -2. **Manual QA Walkthrough** (1 hour) - - Test all user stories (US-UI-04, US-UI-05, US-UI-06) - - Verify all success criteria (SC-001 to SC-010) - - Test dark mode toggle - - Test with different policy types - - Document any issues or enhancements - -**Total Estimated Time**: ~2 hours - ---- - -## Risk Assessment - -### ✅ Mitigated Risks - -- **Graph API Rate Limiting**: Non-blocking cache warming prevents snapshot save failures -- **Definition Schema Changes**: Raw JSONB storage allows future parsing updates -- **Large Policy Rendering**: Accordion lazy-loading via Filament Sections -- **Missing Definitions**: Multi-layer fallback (prettified IDs → warning badges → info messages) - -### ⚠️ Outstanding Risks - -- **Performance with 500+ Settings**: Not tested yet (Phase 7, T042) -- **Graph API Downtime**: Cache helps, but first sync may fail (acceptable trade-off) -- **Browser Compatibility**: Alpine.js clipboard API requires HTTPS (Dokploy provides SSL) - -### ℹ️ Known Limitations - -- **Search**: Client-side only (Blade-level filtering), no debouncing for large policies -- **Value Expansion**: Long strings truncated, no inline expansion (copy button only) -- **Nested Groups**: Flattened in UI, hierarchy not visually preserved - ---- - -## Constitution Compliance - -### ✅ Safety-First -- Read-only feature, no edit capabilities -- Graceful degradation at every layer -- Non-blocking operations (warmCache) - -### ✅ Immutable Versioning -- Snapshot enrichment adds metadata only -- No modification of existing snapshot data -- Definition cache separate from policy snapshots - -### ✅ Defensive Restore -- Not applicable (read-only feature) - -### ✅ Auditability -- Raw JSON still accessible via JSON tab -- Definition resolution logged via Laravel Log -- Graph API calls auditable via GraphLogger - -### ✅ Tenant-Aware -- Resolver respects tenant scoping via GraphClient -- Definitions scoped per tenant (via Graph API calls) - -### ✅ Graph Abstraction -- Uses existing GraphClientInterface (no direct MS Graph SDK calls) -- Follows existing abstraction patterns - -### ✅ Spec-Driven -- Full spec + plan + tasks before implementation -- FR→Task traceability maintained -- Implementation notes added to tasks.md - ---- - -## Deployment Readiness - -### ✅ Local Development (Laravel Sail) -- [X] Database migration applied -- [X] Services registered in container -- [X] Caches cleared -- [X] Code formatted with Pint -- [X] Table exists with data ready for seeding - -### ⏳ Staging Deployment (Dokploy) -- [ ] Run migrations: `php artisan migrate` -- [ ] Clear caches: `php artisan optimize:clear` -- [ ] Verify environment variables (none required for Feature 185) -- [ ] Test with real Intune tenant data -- [ ] Monitor Graph API rate limits - -### ⏳ Production Deployment (Dokploy) -- [ ] Complete staging validation -- [ ] Feature flag enabled (if applicable) -- [ ] Monitor performance metrics -- [ ] Document rollback plan (drop table, revert code) - ---- - -## Support Information - -### Troubleshooting Guide - -**Issue**: Settings tab shows raw definition IDs instead of display names -- **Cause**: Definitions not cached yet -- **Solution**: Wait for next policy sync (SyncPoliciesJob) or manually trigger sync - -**Issue**: Accordion doesn't render, blank Settings tab -- **Cause**: JavaScript error in Blade component -- **Solution**: Check browser console for errors, verify Alpine.js loaded - -**Issue**: "Definitions not cached" message persists -- **Cause**: Graph API call failed during snapshot -- **Solution**: Check logs for Graph API errors, verify permissions for `/deviceManagement/configurationSettings` endpoint - -**Issue**: Performance slow with large policies -- **Cause**: Too many settings rendered at once -- **Solution**: Consider pagination or virtual scrolling (future enhancement) - -### Maintenance Tasks - -- **Cache Clearing**: Run `php artisan cache:clear` if definitions stale -- **Database Cleanup**: Run `SettingsCatalogDefinition::where('updated_at', '<', now()->subDays(30))->delete()` to prune old definitions -- **Performance Monitoring**: Watch `policy_view` page load times in Telescope - ---- - -## Conclusion - -**Implementation Status**: ✅ **CORE COMPLETE** - -Phases 1-5 implemented successfully with: -- ✅ Database schema + model -- ✅ Definition resolver with 3-tier caching -- ✅ Snapshot enrichment with cache warming -- ✅ Normalizer with grouping/formatting -- ✅ UI with accordion, search, and fallback - -**Next Action**: **Phase 6 Manual Verification** (~20 min) - -Navigate to Policy View and verify all features work as expected before proceeding to Phase 7 testing. - -**Estimated Remaining Work**: ~7 hours -- Phase 6: ~20 min -- Phase 7: ~5-7 hours (tests + QA) - -**Feature Delivery Target**: Ready for staging deployment after Phase 7 completion. diff --git a/specs/185-settings-catalog-readable/MANUAL_VERIFICATION_GUIDE.md b/specs/185-settings-catalog-readable/MANUAL_VERIFICATION_GUIDE.md deleted file mode 100644 index c43dda0..0000000 --- a/specs/185-settings-catalog-readable/MANUAL_VERIFICATION_GUIDE.md +++ /dev/null @@ -1,312 +0,0 @@ -# Feature 185: Manual Verification Guide (Phase 6) - -## Quick Start - -**Estimated Time**: 20 minutes -**Prerequisites**: Settings Catalog policy exists in database with snapshot - ---- - -## Verification Steps - -### Step 1: Navigate to Policy View (2 min) - -1. Open browser: `http://localhost` (or your Sail URL) -2. Login to Filament admin panel -3. Navigate to **Policies** resource -4. Click on a **Settings Catalog** policy (look for `settingsCatalogPolicy` type) - -**Expected Result**: -- ✅ Page loads without errors -- ✅ Policy details visible -- ✅ No browser console errors - -**If it fails**: -- Check browser console for JavaScript errors -- Run `./vendor/bin/sail artisan optimize:clear` -- Verify policy has `versions` relationship loaded - ---- - -### Step 2: Verify Tabs Present (2 min) - -**Action**: Look at the Policy View infolist - -**Expected Result**: -- ✅ "Settings" tab visible -- ✅ "JSON" tab visible -- ✅ Settings tab is default (active) - -**If tabs missing**: -- Check if policy is actually Settings Catalog type -- Verify PolicyResource.php has Tabs component for `policy_content` -- Check Feature 002 JSON viewer implementation - ---- - -### Step 3: Verify Settings Tab - Accordion (5 min) - -**Action**: Click on "Settings" tab (if not already active) - -**Expected Result**: -- ✅ Accordion groups render -- ✅ Each group has: - - Title (e.g., "Device Vendor Msft", "Biometric Authentication") - - Description (if available) - - Setting count badge (e.g., "12 settings") -- ✅ First group expanded by default -- ✅ Other groups collapsed -- ✅ Click group header toggles collapse/expand - -**If accordion missing**: -- Check if `metadata.definitions_cached === true` in snapshot -- Verify normalizer returns groups structure -- Check Blade component exists: `settings-catalog-grouped.blade.php` - ---- - -### Step 4: Verify Display Names (Not Definition IDs) (3 min) - -**Action**: Expand a group and look at setting labels - -**Expected Result**: -- ✅ Labels show human-readable names: - - ✅ "Biometric Authentication" (NOT `device_vendor_msft_policy_biometric_authentication`) - - ✅ "Password Minimum Length" (NOT `device_vendor_msft_policy_password_minlength`) -- ✅ No `device_vendor_msft_...` visible in labels - -**If definition IDs visible**: -- Check if definitions cached in database: `SettingsCatalogDefinition::count()` -- Run policy sync manually to trigger cache warming -- Verify fallback message visible: "Definitions not yet cached..." - ---- - -### Step 5: Verify Value Formatting (5 min) - -**Action**: Look at setting values in different groups - -**Expected Result**: -- ✅ **Boolean values**: Badges with "Enabled" (green) or "Disabled" (gray) -- ✅ **Integer values**: Formatted with separators (e.g., "1,000" not "1000") -- ✅ **String values**: Truncated if >100 chars with "..." -- ✅ **Choice values**: Show choice label (not raw ID) - -**If formatting incorrect**: -- Check `formatSettingsCatalogValue()` method in PolicyNormalizer -- Verify Blade component conditionals for value types -- Inspect browser to see actual rendered HTML - ---- - -### Step 6: Test Copy Buttons (2 min) - -**Action**: Find a setting with a long value, click copy button - -**Expected Result**: -- ✅ Copy button visible for long values -- ✅ Click copy button → clipboard receives value -- ✅ Button shows checkmark for 2 seconds -- ✅ Button returns to copy icon after timeout - -**If copy button missing/broken**: -- Check Alpine.js loaded (inspect page source for `@livewireScripts`) -- Verify clipboard API available (requires HTTPS or localhost) -- Check browser console for JavaScript errors - ---- - -### Step 7: Test Search Filtering (Optional - if search visible) (2 min) - -**Action**: Type in search box (if visible at top of Settings tab) - -**Expected Result**: -- ✅ Search box visible with placeholder "Search settings..." -- ✅ Type search query (e.g., "biometric") -- ✅ Only matching settings shown -- ✅ Non-matching groups hidden/empty -- ✅ Clear search resets view - -**If search not visible**: -- This is expected for MVP (Blade-level implementation, no dedicated input yet) -- Search logic exists in Blade template but may need Livewire wiring - ---- - -### Step 8: Verify JSON Tab (2 min) - -**Action**: Click "JSON" tab - -**Expected Result**: -- ✅ Tab switches to JSON view -- ✅ Snapshot renders with syntax highlighting -- ✅ Copy button visible at top -- ✅ Click copy button → full JSON copied to clipboard -- ✅ Can switch back to Settings tab - -**If JSON tab broken**: -- Verify Feature 002 implementation still intact -- Check `pepperfm/filament-json` package installed -- Verify PolicyResource.php has JSON ViewEntry - ---- - -### Step 9: Test Fallback Message (3 min) - -**Action**: Find a Settings Catalog policy WITHOUT cached definitions (or manually delete definitions from database) - -**Steps to test**: -1. Run: `./vendor/bin/sail artisan tinker` -2. Execute: `\App\Models\SettingsCatalogDefinition::truncate();` -3. Navigate to Policy View for Settings Catalog policy -4. Click Settings tab - -**Expected Result**: -- ✅ Settings tab shows fallback message: - - "Definitions not yet cached. Settings will be shown with raw IDs." - - Helper text: "Switch to JSON tab or wait for next sync" -- ✅ JSON tab still accessible -- ✅ No error messages or broken layout - -**If fallback not visible**: -- Check conditional rendering in PolicyResource.php -- Verify `metadata.definitions_cached` correctly set in snapshot -- Check Blade component has fallback TextEntry - ---- - -### Step 10: Test Dark Mode (Optional) (2 min) - -**Action**: Toggle Filament dark mode (if available) - -**Expected Result**: -- ✅ Accordion groups adjust colors -- ✅ Badges adjust colors (dark mode variants) -- ✅ Text remains readable -- ✅ No layout shifts or broken styles - -**If dark mode broken**: -- Check Blade component uses `dark:` Tailwind classes -- Verify Filament Section components support dark mode -- Inspect browser to see actual computed styles - ---- - -## Success Criteria Checklist - -After completing all steps, mark these off: - -- [ ] **T023**: JSON tab works (from Feature 002) -- [ ] **T024**: Fallback message shows when definitions not cached -- [ ] **T025**: JSON viewer only renders on Policy View (not globally) - ---- - -## Common Issues & Solutions - -### Issue: "Definitions not yet cached" persists - -**Cause**: SyncPoliciesJob hasn't run yet or Graph API call failed - -**Solution**: -1. Manually trigger sync: - ```bash - ./vendor/bin/sail artisan tinker - ``` - ```php - $policy = \App\Models\Policy::first(); - \App\Jobs\SyncPoliciesJob::dispatch(); - ``` -2. Check logs for Graph API errors: - ```bash - ./vendor/bin/sail artisan log:show - ``` - -### Issue: Accordion doesn't render - -**Cause**: Blade component error or missing groups - -**Solution**: -1. Check browser console for errors -2. Verify normalizer output: - ```bash - ./vendor/bin/sail artisan tinker - ``` - ```php - $policy = \App\Models\Policy::first(); - $snapshot = $policy->versions()->orderByDesc('captured_at')->value('snapshot'); - $normalizer = app(\App\Services\Intune\PolicyNormalizer::class); - $groups = $normalizer->normalizeSettingsCatalogGrouped($snapshot['settings'] ?? []); - dd($groups); - ``` - -### Issue: Copy buttons don't work - -**Cause**: Alpine.js not loaded or clipboard API unavailable - -**Solution**: -1. Verify Alpine.js loaded: - - Open browser console - - Type `window.Alpine` → should return object -2. Check HTTPS or localhost (clipboard API requires secure context) -3. Fallback: Use "View JSON" tab and copy from there - ---- - -## Next Steps After Verification - -### If All Tests Pass ✅ - -Proceed to **Phase 7: Testing & Validation** - -1. Write unit tests (T026-T031) -2. Write feature tests (T032-T037) -3. Run Pest suite (T038-T039) -4. Manual QA walkthrough (T040-T042) - -**Estimated Time**: ~5-7 hours - -### If Issues Found ⚠️ - -1. Document issues in `specs/185-settings-catalog-readable/ISSUES.md` -2. Fix critical issues (broken UI, errors) -3. Re-run verification steps -4. Proceed to Phase 7 only after verification passes - ---- - -## Reporting Results - -After completing verification, update tasks.md: - -```bash -# Mark T023-T025 as complete -vim specs/185-settings-catalog-readable/tasks.md -``` - -Add implementation notes: -```markdown -- [X] **T023** Verify JSON tab still works - - **Implementation Note**: Verified tabs functional, JSON viewer renders snapshot - -- [X] **T024** Add fallback for policies without cached definitions - - **Implementation Note**: Fallback message shows info with guidance to JSON tab - -- [X] **T025** Ensure JSON viewer only renders on Policy View - - **Implementation Note**: Verified scoping correct, only shows on Policy resource -``` - ---- - -## Contact & Support - -If verification fails or you need assistance: - -1. Check logs: `./vendor/bin/sail artisan log:show` -2. Review implementation status: `specs/185-settings-catalog-readable/IMPLEMENTATION_STATUS.md` -3. Review code: `app/Services/Intune/`, `app/Filament/Resources/PolicyResource.php` -4. Ask for help with specific error messages and context - ---- - -**End of Manual Verification Guide** diff --git a/specs/185-settings-catalog-readable/plan.md b/specs/185-settings-catalog-readable/plan.md deleted file mode 100644 index e096851..0000000 --- a/specs/185-settings-catalog-readable/plan.md +++ /dev/null @@ -1,414 +0,0 @@ -# Feature 185: Implementation Plan - -## Tech Stack -- **Backend**: Laravel 12, PHP 8.4 -- **Database**: PostgreSQL (JSONB for raw definition storage) -- **Frontend**: Filament 4, Livewire 3, Tailwind CSS -- **Graph Client**: Existing `GraphClientInterface` -- **JSON Viewer**: `pepperfm/filament-json` (installed) - -## Architecture Overview - -### Services Layer -``` -app/Services/Intune/ -├── SettingsCatalogDefinitionResolver.php (NEW) -├── PolicyNormalizer.php (EXTEND) -└── PolicySnapshotService.php (EXTEND) -``` - -### Database Layer -``` -database/migrations/ -└── xxxx_create_settings_catalog_definitions_table.php (NEW) - -app/Models/ -└── SettingsCatalogDefinition.php (NEW) -``` - -### UI Layer -``` -app/Filament/Resources/ -├── PolicyResource.php (EXTEND - infolist with tabs) -└── PolicyVersionResource.php (FUTURE - optional) - -resources/views/filament/infolists/entries/ -└── settings-catalog-grouped.blade.php (NEW - accordion view) -``` - -## Component Responsibilities - -### 1. SettingsCatalogDefinitionResolver -**Purpose**: Fetch and cache setting definitions from Graph API - -**Key Methods**: -- `resolve(array $definitionIds): array` - Batch resolve definitions -- `resolveOne(string $definitionId): ?array` - Single definition lookup -- `warmCache(array $definitionIds): void` - Pre-populate cache -- `clearCache(?string $definitionId = null): void` - Cache invalidation - -**Dependencies**: -- `GraphClientInterface` - Graph API calls -- `SettingsCatalogDefinition` model - Database cache -- Laravel Cache - Memory-level cache - -**Caching Strategy**: -1. Check memory cache (request-level) -2. Check database cache (30-day TTL) -3. Fetch from Graph API -4. Store in DB + memory - -**Graph Endpoints**: -- `/deviceManagement/configurationSettings` (global catalog) -- `/deviceManagement/configurationPolicies/{id}/settings/{settingId}/settingDefinitions` (policy-specific) - -### 2. PolicyNormalizer (Extension) -**Purpose**: Transform Settings Catalog snapshot into UI-ready structure - -**New Method**: `normalizeSettingsCatalog(array $snapshot, array $definitions): array` - -**Output Structure**: -```php -[ - 'type' => 'settings_catalog', - 'groups' => [ - [ - 'title' => 'Windows Hello for Business', - 'description' => 'Configure biometric authentication settings', - 'settings' => [ - [ - 'label' => 'Use biometrics', - 'value_display' => 'Enabled', - 'value_raw' => true, - 'help_text' => 'Allow users to sign in with fingerprint...', - 'definition_id' => 'device_vendor_msft_passportforwork_biometrics_usebiometrics', - 'instance_type' => 'ChoiceSettingInstance' - ] - ] - ] - ] -] -``` - -**Value Formatting Rules**: -- `ChoiceSettingInstance`: Extract choice label from `@odata.type` or value -- `SimpleSetting` (bool): "Enabled" / "Disabled" -- `SimpleSetting` (int): Number formatted with separators -- `SimpleSetting` (string): Truncate >100 chars, add "..." -- `GroupSettingCollectionInstance`: Flatten children recursively - -**Grouping Strategy**: -- Group by `categoryId` from definition metadata -- Fallback: Group by first segment of definition ID (e.g., `device_vendor_msft_`) -- Sort groups alphabetically - -### 3. PolicySnapshotService (Extension) -**Purpose**: Enrich snapshots with definition metadata after hydration - -**Modified Flow**: -``` -1. Hydrate settings from Graph (existing) -2. Extract all settingDefinitionId + children (NEW) -3. Call SettingsCatalogDefinitionResolver::warmCache() (NEW) -4. Add metadata to snapshot: definitions_cached, definition_count (NEW) -5. Save snapshot (existing) -``` - -**Non-Blocking**: Definition resolution should not block policy sync -- Use try/catch for Graph API calls -- Mark `definitions_cached: false` on failure -- Continue with snapshot save - -### 4. PolicyResource (UI Extension) -**Purpose**: Render Settings Catalog policies with readable UI - -**Changes**: -1. Add Tabs component to infolist: - - "Settings" tab (default) - - "JSON" tab (existing Feature 002 implementation) - -2. Settings Tab Structure: - - Search/filter input (top) - - Accordion component (groups) - - Each group: Section with settings table - - Fallback: Show info message if no definitions cached - -3. JSON Tab: - - Existing implementation from Feature 002 - - Shows full snapshot with copy button - -**Conditional Rendering**: -- Show tabs ONLY for `settingsCatalogPolicy` type -- For other policy types: Keep existing simple sections - -## Database Schema - -### Table: `settings_catalog_definitions` -```sql -CREATE TABLE settings_catalog_definitions ( - id BIGSERIAL PRIMARY KEY, - definition_id VARCHAR(500) UNIQUE NOT NULL, - display_name VARCHAR(255) NOT NULL, - description TEXT, - help_text TEXT, - category_id VARCHAR(255), - ux_behavior VARCHAR(100), - raw JSONB NOT NULL, - created_at TIMESTAMP, - updated_at TIMESTAMP -); - -CREATE INDEX idx_definition_id ON settings_catalog_definitions(definition_id); -CREATE INDEX idx_category_id ON settings_catalog_definitions(category_id); -CREATE INDEX idx_raw_gin ON settings_catalog_definitions USING GIN(raw); -``` - -**Indexes**: -- `definition_id` - Primary lookup key -- `category_id` - Grouping queries -- `raw` (GIN) - JSONB queries if needed - -## Graph API Integration - -### Endpoints Used - -1. **Global Catalog** (Preferred): -``` -GET /deviceManagement/configurationSettings -GET /deviceManagement/configurationSettings/{settingDefinitionId} -``` - -2. **Policy-Specific** (Fallback): -``` -GET /deviceManagement/configurationPolicies/{policyId}/settings/{settingId}/settingDefinitions -``` - -### Request Optimization -- Batch requests where possible -- Use `$select` to limit fields -- Use `$filter` for targeted lookups -- Respect rate limits (429 retry logic) - -## UI/UX Flow - -### Policy View Page Flow -1. User navigates to `/admin/policies/{id}` -2. Policy details loaded (existing) -3. Check policy type: - - If `settingsCatalogPolicy`: Show tabs - - Else: Show existing sections -4. Default to "Settings" tab -5. Load normalized settings from PolicyNormalizer -6. Render accordion with groups -7. User can search/filter settings -8. User can switch to "JSON" tab for raw view - -### Settings Tab Layout -``` -┌─────────────────────────────────────────────┐ -│ [Search settings...] [🔍] │ -├─────────────────────────────────────────────┤ -│ ▼ Windows Hello for Business │ -│ ├─ Use biometrics: Enabled │ -│ ├─ Use facial recognition: Disabled │ -│ └─ PIN minimum length: 6 │ -├─────────────────────────────────────────────┤ -│ ▼ Device Lock Settings │ -│ ├─ Password expiration days: 90 │ -│ └─ Password history: 5 │ -└─────────────────────────────────────────────┘ -``` - -### JSON Tab Layout -``` -┌─────────────────────────────────────────────┐ -│ Full Policy Configuration [Copy] │ -├─────────────────────────────────────────────┤ -│ { │ -│ "@odata.type": "...", │ -│ "name": "WHFB Settings", │ -│ "settings": [...] │ -│ } │ -└─────────────────────────────────────────────┘ -``` - -## Error Handling - -### Definition Not Found -- **UI**: Show prettified definition ID -- **Label**: Convert `device_vendor_msft_policy_name` → "Device Vendor Msft Policy Name" -- **Icon**: Info icon with tooltip "Definition not cached" - -### Graph API Failure -- **During Sync**: Mark `definitions_cached: false`, continue -- **During View**: Show cached data or fallback labels -- **Log**: Record Graph API errors for debugging - -### Malformed Snapshot -- **Validation**: Check for required fields before normalization -- **Fallback**: Show raw JSON tab, hide Settings tab -- **Warning**: Display admin-friendly error message - -## Performance Considerations - -### Database Queries -- Eager load definitions for all settings in one query -- Use `whereIn()` for batch lookups -- Index on `definition_id` ensures fast lookups - -### Memory Management -- Request-level cache using Laravel Cache -- Limit batch size to 100 definitions per request -- Clear memory cache after request - -### UI Rendering -- Accordion lazy-loads groups (only render expanded) -- Pagination for policies with >50 groups -- Virtualized list for very large policies (future) - -### Caching TTL -- Database: 30 days (definitions change rarely) -- Memory: Request duration only -- Background refresh: Optional scheduled job - -## Security Considerations - -### Graph API Permissions -- Existing `DeviceManagementConfiguration.Read.All` sufficient -- No new permissions required - -### Data Sanitization -- Escape HTML in display names and descriptions -- Validate definition ID format before lookups -- Prevent XSS in value rendering - -### Audit Logging -- Log definition cache misses -- Log Graph API failures -- Track definition cache updates - -## Testing Strategy - -### Unit Tests -**File**: `tests/Unit/SettingsCatalogDefinitionResolverTest.php` -- Test batch resolution -- Test caching behavior (memory + DB) -- Test fallback when definition not found -- Test Graph API error handling - -**File**: `tests/Unit/PolicyNormalizerSettingsCatalogTest.php` -- Test grouping logic -- Test value formatting (bool, int, choice, string) -- Test fallback labels -- Test nested group flattening - -### Feature Tests -**File**: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` -- Mock Graph API responses -- Assert tabs present for Settings Catalog policies -- Assert display names shown (not definition IDs) -- Assert values formatted correctly -- Assert search/filter works -- Assert JSON tab accessible -- Assert graceful degradation for missing definitions - -### Manual QA Checklist -1. Open Policy View for Settings Catalog policy -2. Verify tabs present: "Settings" and "JSON" -3. Verify Settings tab shows groups with accordion -4. Verify display names shown (not raw IDs) -5. Verify values formatted (True/False, numbers, etc.) -6. Test search: Type setting name, verify filtering -7. Switch to JSON tab, verify snapshot shown -8. Test copy button in JSON tab -9. Test dark mode toggle -10. Test with policy missing definitions (fallback labels) - -## Deployment Steps - -### 1. Database Migration -```bash -./vendor/bin/sail artisan migrate -``` - -### 2. Cache Warming (Optional) -```bash -./vendor/bin/sail artisan tinker ->>> $resolver = app(\App\Services\Intune\SettingsCatalogDefinitionResolver::class); ->>> $resolver->warmCache([...definitionIds...]); -``` - -### 3. Clear Caches -```bash -./vendor/bin/sail artisan optimize:clear -``` - -### 4. Verify -- Navigate to Policy View -- Check browser console for errors -- Check Laravel logs for Graph API errors - -## Rollback Plan - -### If Critical Issues Found -1. Revert database migration: - ```bash - ./vendor/bin/sail artisan migrate:rollback - ``` - -2. Revert code changes (Git): - ```bash - git revert - ``` - -3. Clear caches: - ```bash - ./vendor/bin/sail artisan optimize:clear - ``` - -### Partial Rollback -- Remove tabs, keep existing table view -- Disable definition resolver, show raw IDs -- Keep database table for future use - -## Dependencies on Feature 002 - -**Shared**: -- `pepperfm/filament-json` package (installed) -- JSON viewer CSS assets (published) -- Tab component pattern (Filament Schemas) - -**Independent**: -- Feature 185 can work without Feature 002 completed -- Feature 002 provided JSON tab foundation -- Feature 185 adds Settings tab with readable UI - -## Timeline Estimate - -- **Phase 1** (Foundation): 2-3 hours -- **Phase 2** (Snapshot): 1 hour -- **Phase 3** (Normalizer): 2-3 hours -- **Phase 4** (UI): 3-4 hours -- **Phase 5** (Testing): 2-3 hours -- **Total**: ~11-15 hours - -## Success Metrics - -1. **User Experience**: - - Admins can read policy settings without raw JSON - - Search finds settings in <200ms - - Accordion groups reduce scrolling - -2. **Performance**: - - Definition resolution: <500ms for 50 definitions - - UI render: <2s for 200 settings - - Search response: <200ms - -3. **Quality**: - - 100% test coverage for resolver - - Zero broken layouts for missing definitions - - Zero Graph API errors logged (with proper retry) - -4. **Adoption**: - - Settings tab used >80% of time vs JSON tab - - Zero support tickets about "unreadable settings" diff --git a/specs/185-settings-catalog-readable/spec.md b/specs/185-settings-catalog-readable/spec.md deleted file mode 100644 index 65fbba7..0000000 --- a/specs/185-settings-catalog-readable/spec.md +++ /dev/null @@ -1,240 +0,0 @@ -# Feature 185: Intune-like "Cleartext Settings" on Policy View - -## Overview -Display Settings Catalog policies in Policy View with human-readable setting names, descriptions, and formatted values—similar to Intune Portal experience—instead of raw JSON and definition IDs. - -## Problem Statement -Admins cannot effectively work with Settings Catalog policies when they only see: -- `settingDefinitionId` strings (e.g., `device_vendor_msft_passportforwork_biometrics_usebiometrics`) -- Raw JSON structures -- Choice values as GUIDs or internal strings - -This makes policy review, audit, and troubleshooting extremely difficult. - -## Goals -- **Primary**: Render Settings Catalog policies with display names, descriptions, grouped settings, and formatted values -- **Secondary**: Keep raw JSON available for audit/restore workflows -- **Tertiary**: Gracefully degrade when definition metadata is unavailable - -## User Stories - -### P1: US-UI-04 - Admin Views Readable Settings -**As an** Intune admin -**I want to** see policy settings with human-readable names and descriptions -**So that** I can understand what the policy configures without reading raw JSON - -**Acceptance Criteria:** -- Display name shown for each setting (not definition ID) -- Description/help text visible on hover or expand -- Values formatted appropriately (True/False, numbers, choice labels) -- Settings grouped by category/section - -### P2: US-UI-05 - Admin Searches/Filters Settings -**As an** Intune admin -**I want to** search and filter settings by name or value -**So that** I can quickly find specific configurations in large policies - -**Acceptance Criteria:** -- Search box filters settings list -- Search works on display name and value -- Results update instantly -- Clear search resets view - -### P3: US-UI-06 - Admin Accesses Raw JSON When Needed -**As an** Intune admin or auditor -**I want to** switch to raw JSON view -**So that** I can see the exact Graph API payload for audit/restore - -**Acceptance Criteria:** -- Tab navigation between "Settings" and "JSON" views -- JSON view shows complete policy snapshot -- JSON view includes copy-to-clipboard -- Settings view is default - -## Functional Requirements - -### FR-185.1: Setting Definition Resolver Service -- **Input**: Array of `settingDefinitionId` (including children from group settings) -- **Output**: Map of `{definitionId => {displayName, description, helpText, categoryId, uxBehavior, ...}}` -- **Strategy**: - - Fetch from Graph API settingDefinitions endpoints - - Cache in database (`settings_catalog_definitions` table) - - Memory cache for request-level performance - - Fallback to prettified ID if definition not found - -### FR-185.2: Database Schema for Definition Cache -**Table**: `settings_catalog_definitions` -- `id` (bigint, PK) -- `definition_id` (string, unique, indexed) -- `display_name` (string) -- `description` (text, nullable) -- `help_text` (text, nullable) -- `category_id` (string, nullable) -- `ux_behavior` (string, nullable) -- `raw` (jsonb) - full Graph response -- `timestamps` - -### FR-185.3: Snapshot Enrichment (Non-Blocking) -- After hydrating `/configurationPolicies/{id}/settings` -- Extract all `settingDefinitionId` + children -- Call resolver to warm cache -- Store render hints in snapshot metadata: `definitions_cached: true/false`, `definition_count: N` - -### FR-185.4: PolicyNormalizer Enhancement -- For `settingsCatalogPolicy` type: - - Output: `settings_groups[]` = `{title, description?, rows[]}` - - Each row: `{label, helpText?, value_display, value_raw, definition_id, instance_type}` - - Value formatting: - - `integer/bool`: show compact (True/False, numbers) - - `choice`: show friendly choice label (extract from `@odata.type` or value tail) - - `string`: truncate long values, add copy button - - Fallback: prettify `definitionId` if definition not found (e.g., `device_vendor_msft_policy_name` → "Device Vendor Msft Policy Name") - -### FR-185.5: Policy View UI Update -- **Layout**: 2-column - - Left: "Configuration Settings" (grouped, searchable) - - Right: "Policy Details" (existing metadata: name, type, platform, last synced) -- **Tabs**: - - "Settings" (default) - cleartext UI with accordion groups - - "JSON" - raw snapshot viewer (pepperfm/filament-json) -- **Search/Filter**: Live search on setting display name and value -- **Accordion**: Settings grouped by category, collapsible -- **Fallback**: Generic table for non-Settings Catalog policies (existing behavior) - -### FR-185.6: JSON Viewer Integration -- Use `pepperfm/filament-json` only on Policy View and Policy Version View -- Not rendered globally - -## Non-Functional Requirements - -### NFR-185.1: Performance -- Definition resolver: <500ms for batch of 50 definitions (cached) -- UI render: <2s for policy with 200 settings -- Search/filter: <200ms response time - -### NFR-185.2: Caching Strategy -- DB cache: 30 days TTL for definitions -- Memory cache: Request-level only -- Cache warming: Background job after policy sync (optional) - -### NFR-185.3: Graceful Degradation -- If definition not found: show prettified ID -- If Graph API fails: show cached data or fallback -- If no cache: show raw definition ID with info icon - -### NFR-185.4: Maintainability -- Resolver service isolated, testable -- Normalizer logic separated from UI -- UI components reusable for Version view - -## Technical Architecture - -### Services -1. **SettingsCatalogDefinitionResolver** (`app/Services/Intune/`) - - `resolve(array $definitionIds): array` - - `resolveOne(string $definitionId): ?array` - - `warmCache(array $definitionIds): void` - - Uses GraphClientInterface - - Database: `SettingsCatalogDefinition` model - -2. **PolicyNormalizer** (extend existing) - - `normalizeSettingsCatalog(array $snapshot, array $definitions): array` - - Returns structured groups + rows - -### Database -**Migration**: `create_settings_catalog_definitions_table` -**Model**: `SettingsCatalogDefinition` (Eloquent) - -### UI Components -**Resource**: `PolicyResource` (extend infolist) -- Tabs component -- Accordion for groups -- Search/filter component -- ViewEntry for settings table - -## Implementation Plan - -### Phase 1: Foundation (Resolver + DB) -1. Create migration `settings_catalog_definitions` -2. Create model `SettingsCatalogDefinition` -3. Create service `SettingsCatalogDefinitionResolver` -4. Add Graph client method for fetching definitions -5. Implement cache logic (DB + memory) - -### Phase 2: Snapshot Enrichment -1. Extend `PolicySnapshotService` to extract definition IDs -2. Call resolver after settings hydration -3. Store metadata in snapshot - -### Phase 3: Normalizer Enhancement -1. Extend `PolicyNormalizer` for Settings Catalog -2. Implement value formatting logic -3. Implement grouping logic -4. Add fallback for missing definitions - -### Phase 4: UI Implementation -1. Update `PolicyResource` infolist with tabs -2. Create accordion view for settings groups -3. Add search/filter functionality -4. Integrate JSON viewer (pepperfm) -5. Add fallback for non-Settings Catalog policies - -### Phase 5: Testing & Polish -1. Unit tests for resolver -2. Feature tests for UI -3. Manual QA on staging -4. Performance profiling - -## Testing Strategy - -### Unit Tests -- `SettingsCatalogDefinitionResolverTest` - - Test definition mapping - - Test caching behavior - - Test fallback logic - - Test batch resolution - -### Feature Tests -- `PolicyViewSettingsCatalogReadableTest` - - Mock Graph responses - - Assert UI shows display names - - Assert values formatted correctly - - Assert grouping works - - Assert search/filter works - - Assert JSON tab available - -## Success Criteria - -1. ✅ Admin sees human-readable setting names + descriptions -2. ✅ Values formatted appropriately (True/False, numbers, choice labels) -3. ✅ Settings grouped by category with accordion -4. ✅ Search/filter works on display name and value -5. ✅ Raw JSON available in separate tab -6. ✅ Unknown settings show prettified ID (no broken layout) -7. ✅ Performance: <2s render for 200 settings -8. ✅ Tests pass: Unit + Feature - -## Dependencies -- Existing: `PolicyNormalizer`, `PolicySnapshotService`, `GraphClientInterface` -- New: `pepperfm/filament-json` (already installed in Feature 002) -- Database: PostgreSQL with JSONB support - -## Risks & Mitigations -- **Risk**: Graph API rate limiting when fetching definitions - - **Mitigation**: Aggressive caching, batch requests, background warming -- **Risk**: Definition schema changes by Microsoft - - **Mitigation**: Raw JSONB storage allows flexible parsing, version metadata -- **Risk**: Large policies (1000+ settings) slow UI - - **Mitigation**: Pagination, lazy loading accordion groups, virtualized lists - -## Out of Scope -- Editing settings (read-only view only) -- Definition schema versioning -- Multi-language support for definitions -- Real-time definition updates (cache refresh manual/scheduled) - -## Future Enhancements -- Background job to pre-warm definition cache -- Definition schema versioning -- Comparison view between policy versions (diff) -- Export settings to CSV/Excel diff --git a/specs/185-settings-catalog-readable/tasks.md b/specs/185-settings-catalog-readable/tasks.md deleted file mode 100644 index 556b5c1..0000000 --- a/specs/185-settings-catalog-readable/tasks.md +++ /dev/null @@ -1,472 +0,0 @@ -# Feature 185: Settings Catalog Readable UI - Tasks - -## Summary -- **Total Tasks**: 42 -- **User Stories**: 3 (US-UI-04, US-UI-05, US-UI-06) -- **Estimated Time**: 11-15 hours -- **Phases**: 7 - -## FR→Task Traceability - -| FR | Description | Tasks | -|----|-------------|-------| -| FR-185.1 | Setting Definition Resolver Service | T003, T004, T005, T006, T007 | -| FR-185.2 | Database Schema | T001, T002 | -| FR-185.3 | Snapshot Enrichment | T008, T009, T010 | -| FR-185.4 | PolicyNormalizer Enhancement | T011, T012, T013, T014 | -| FR-185.5 | Policy View UI Update | T015-T024 | -| FR-185.6 | JSON Viewer Integration | T025 | - -## User Story→Task Mapping - -| User Story | Tasks | Success Criteria | -|------------|-------|------------------| -| US-UI-04 (Readable Settings) | T015-T020 | Display names shown, values formatted, grouped by category | -| US-UI-05 (Search/Filter) | T021, T022 | Search box works, filters settings, instant results | -| US-UI-06 (Raw JSON Access) | T023, T024, T025 | Tabs present, JSON view works, copy button functional | - -## Measurable Thresholds -- **Definition Resolution**: <500ms for batch of 50 definitions (cached) -- **UI Render**: <2s for policy with 200 settings -- **Search Response**: <200ms filter update -- **Database Cache TTL**: 30 days - ---- - -## Phase 1: Database Foundation (T001-T002) - -**Goal**: Create database schema for caching setting definitions - -- [X] **T001** Create migration for `settings_catalog_definitions` table - - Schema: id, definition_id (unique), display_name, description, help_text, category_id, ux_behavior, raw (jsonb), timestamps - - Indexes: definition_id (unique), category_id, raw (GIN) - - File: `database/migrations/2025_12_13_212126_create_settings_catalog_definitions_table.php` - - **Implementation Note**: Created migration with GIN index for JSONB, ran successfully - -- [X] **T002** Create `SettingsCatalogDefinition` Eloquent model - - Casts: raw → array - - Fillable: definition_id, display_name, description, help_text, category_id, ux_behavior, raw - - File: `app/Models/SettingsCatalogDefinition.php` - - **Implementation Note**: Added helper methods findByDefinitionId() and findByDefinitionIds() for efficient lookups - ---- - -## Phase 2: Definition Resolver Service (T003-T007) - -**Goal**: Implement service to fetch and cache setting definitions from Graph API - -**User Story**: US-UI-04 (foundation) - -- [X] **T003** [P] Create `SettingsCatalogDefinitionResolver` service skeleton - - Constructor: inject GraphClientInterface, SettingsCatalogDefinition model - - Methods: resolve(), resolveOne(), warmCache(), clearCache() - - File: `app/Services/Intune/SettingsCatalogDefinitionResolver.php` - - **Implementation Note**: Complete service with 3-tier caching (memory → DB → Graph API) - -- [X] **T004** [P] [US1] Implement `resolve(array $definitionIds): array` method - - Check memory cache (Laravel Cache) - - Check database cache - - Batch fetch missing from Graph API: `/deviceManagement/configurationSettings?$filter=id in (...)` - - Store in DB + memory cache - - Return map: `{definitionId => {displayName, description, ...}}` - - File: `app/Services/Intune/SettingsCatalogDefinitionResolver.php` - - **Implementation Note**: Implemented with batch request optimization and error handling - -- [X] **T005** [P] [US1] Implement `resolveOne(string $definitionId): ?array` method - - Single definition lookup - - Same caching strategy as resolve() - - Return null if not found - - File: `app/Services/Intune/SettingsCatalogDefinitionResolver.php` - - **Implementation Note**: Wraps resolve() for single ID lookup - -- [X] **T006** [US1] Implement fallback logic for missing definitions - - Prettify definition ID: `device_vendor_msft_policy_name` → "Device Vendor Msft Policy Name" - - Return fallback structure: `{displayName: prettified, description: null, ...}` - - File: `app/Services/Intune/SettingsCatalogDefinitionResolver.php` - - **Implementation Note**: prettifyDefinitionId() method with Str::title() conversion, isFallback flag added - -- [X] **T007** [P] Implement `warmCache(array $definitionIds): void` method - - Pre-populate cache without returning data - - Non-blocking: catch and log Graph API errors - - File: `app/Services/Intune/SettingsCatalogDefinitionResolver.php` - - **Implementation Note**: Non-blocking implementation with try/catch, logs warnings on failure - ---- - -## Phase 3: Snapshot Enrichment (T008-T010) - -**Goal**: Extend PolicySnapshotService to warm definition cache after settings hydration - -**User Story**: US-UI-04 (foundation) - -- [X] **T008** [US1] Extend `PolicySnapshotService` to extract definition IDs - - After hydrating `/configurationPolicies/{id}/settings` - - Extract all `settingDefinitionId` from settings array - - Include children from `groupSettingCollectionInstance` - - File: `app/Services/Intune/PolicySnapshotService.php` - - **Implementation Note**: Added extractDefinitionIds() method with recursive extraction from nested children - -- [X] **T009** [US1] Call SettingsCatalogDefinitionResolver::warmCache() in snapshot flow - - Pass extracted definition IDs to resolver - - Non-blocking: use try/catch for Graph API calls - - File: `app/Services/Intune/PolicySnapshotService.php` - - **Implementation Note**: Integrated warmCache() call in hydrateSettingsCatalog() after settings extraction - -- [X] **T010** [US1] Add metadata to snapshot about definition cache status - - Add to snapshot: `definitions_cached: true/false`, `definition_count: N` - - Store with snapshot data - - File: `app/Services/Intune/PolicySnapshotService.php` - - **Implementation Note**: Added definitions_cached and definition_count to metadata - ---- - -## Phase 4: PolicyNormalizer Enhancement (T011-T014) - -**Goal**: Transform Settings Catalog snapshots into UI-ready grouped structure - -**User Story**: US-UI-04 - -- [X] **T011** [US1] Create `normalizeSettingsCatalogGrouped()` method in PolicyNormalizer - - Input: array $snapshot, array $definitions - - Output: array with groups[] structure - - Extract settings from snapshot - - Resolve definitions for all setting IDs - - File: `app/Services/Intune/PolicyNormalizer.php` - - **Implementation Note**: Complete method with definition resolution integration - -- [X] **T012** [US1] Implement value formatting logic - - ChoiceSettingInstance: Extract choice label from @odata.type or value - - SimpleSetting (bool): "Enabled" / "Disabled" - - SimpleSetting (int): Number formatted with separators - - SimpleSetting (string): Truncate >100 chars, add "..." - - File: `app/Services/Intune/PolicyNormalizer.php` - - **Implementation Note**: Added formatSettingsCatalogValue() method with all formatting rules - -- [X] **T013** [US1] Implement grouping logic by category - - Group settings by categoryId from definition metadata - - Fallback: Group by first segment of definition ID - - Sort groups alphabetically by title - - File: `app/Services/Intune/PolicyNormalizer.php` - - **Implementation Note**: Added groupSettingsByCategory() with fallback extraction from definition IDs - -- [X] **T014** [US1] Implement nested group flattening for groupSettingCollectionInstance - - Recursively extract children from group settings - - Maintain hierarchy in output structure - - Include parent context in child labels - - File: `app/Services/Intune/PolicyNormalizer.php` - - **Implementation Note**: Recursive walk function handles nested children and group collections - ---- - -## Phase 5: UI Implementation - Settings Tab (T015-T022) - -**Goal**: Create readable Settings Catalog UI with accordion, search, and formatting - -**User Stories**: US-UI-04, US-UI-05 - -- [X] **T015** [US1] Add Tabs component to PolicyResource infolist for settingsCatalogPolicy - - Conditional rendering: only for settingsCatalogPolicy type - - Tab 1: "Settings" (default) - - Tab 2: "JSON" (existing from Feature 002) - - File: `app/Filament/Resources/PolicyResource.php` - - **Implementation Note**: Tabs already exist from Feature 002, extended Settings tab with grouped view - -- [X] **T016** [US1] Create Settings tab schema with search input - - TextInput for search/filter at top - - Placeholder: "Search settings..." - - Wire with Livewire for live filtering - - File: `app/Filament/Resources/PolicyResource.php` - - **Implementation Note**: Added search_info TextEntry (hidden for MVP), search implemented in Blade template - -- [X] **T017** [US1] Create Blade component for grouped settings accordion - - File: `resources/views/filament/infolists/entries/settings-catalog-grouped.blade.php` - - Props: groups (from normalizer), searchQuery - - Render accordion with Filament Section components - - **Implementation Note**: Complete Blade component with Filament Section integration - -- [X] **T018** [US1] Implement accordion group rendering - - Each group: Section with title + description - - Collapsible by default (first group expanded) - - Group header shows setting count - - File: `settings-catalog-grouped.blade.php` - - **Implementation Note**: Using x-filament::section with collapsible, first group expanded by default - -- [X] **T019** [US1] Implement setting row rendering within groups - - Layout: Label (bold) | Value (formatted) | Help icon - - Help icon: Tooltip with description + helpText - - Copy button for long values - - File: `settings-catalog-grouped.blade.php` - - **Implementation Note**: Flexbox layout with label, help text, value display, and Alpine.js copy button - -- [X] **T020** [US1] Add value formatting in Blade template - - Bool: Badge (Enabled/Disabled with colors) - - Int: Formatted number - - String: Truncate with "..." and expand button - - Choice: Show choice label - - File: `settings-catalog-grouped.blade.php` - - **Implementation Note**: Conditional rendering based on value type, badges for bool, monospace for int - -- [X] **T021** [US2] Implement search/filter logic in Livewire component - - Filter groups and settings by search query - - Search on display_name and value_display - - Update accordion to show only matching settings - - File: `app/Filament/Resources/PolicyResource.php` (or custom Livewire component) - - **Implementation Note**: Blade-level filtering using searchQuery prop, no Livewire component needed for MVP - -- [X] **T022** [US2] Add "No results" empty state for search - - Show message when search returns no matches - - Provide "Clear search" button - - File: `settings-catalog-grouped.blade.php` - - **Implementation Note**: Empty state with clear search button using wire:click - ---- - -## Phase 6: UI Implementation - Tabs & Fallback (T023-T025) - -**Goal**: Complete tab navigation and handle non-Settings Catalog policies - -**User Story**: US-UI-06 - -- [ ] **T023** [US3] Verify JSON tab still works (from Feature 002) - - Tab navigation switches correctly - - JSON viewer renders snapshot - - Copy button functional - - File: `app/Filament/Resources/PolicyResource.php` - -- [ ] **T024** [US3] Add fallback for policies without cached definitions - - Show info message in Settings tab: "Definitions not cached. Showing raw data." - - Display raw definition IDs with prettified labels - - Link to "View JSON" tab - - File: `settings-catalog-grouped.blade.php` - -- [ ] **T025** Ensure JSON viewer only renders on Policy View (not globally) - - Check existing implementation from Feature 002 - - Verify pepperfm/filament-json scoped correctly - - File: `app/Filament/Resources/PolicyResource.php` - ---- - -## Phase 7: Testing & Validation (T026-T042) - -**Goal**: Comprehensive testing for resolver, normalizer, and UI - -### Unit Tests (T026-T031) - -- [ ] **T026** [P] Create `SettingsCatalogDefinitionResolverTest` test file - - File: `tests/Unit/SettingsCatalogDefinitionResolverTest.php` - - Setup: Mock GraphClientInterface, in-memory database - -- [ ] **T027** [P] Test `resolve()` method with batch of definition IDs - - Assert: Returns map with display names - - Assert: Caches in database - - Assert: Uses cached data on second call - - File: `tests/Unit/SettingsCatalogDefinitionResolverTest.php` - -- [ ] **T028** [P] Test fallback logic for missing definitions - - Mock: Graph API returns 404 - - Assert: Returns prettified definition ID - - Assert: No exception thrown - - File: `tests/Unit/SettingsCatalogDefinitionResolverTest.php` - -- [ ] **T029** [P] Create `PolicyNormalizerSettingsCatalogTest` test file - - File: `tests/Unit/PolicyNormalizerSettingsCatalogTest.php` - - Setup: Mock definition data, sample snapshot - -- [ ] **T030** [P] Test grouping logic in normalizer - - Input: Snapshot with settings from different categories - - Assert: Groups created correctly - - Assert: Groups sorted alphabetically - - File: `tests/Unit/PolicyNormalizerSettingsCatalogTest.php` - -- [ ] **T031** [P] Test value formatting in normalizer - - Test bool → "Enabled"/"Disabled" - - Test int → formatted number - - Test string → truncation - - Test choice → label extraction - - File: `tests/Unit/PolicyNormalizerSettingsCatalogTest.php` - -### Feature Tests (T032-T037) - -- [ ] **T032** [P] Create `PolicyViewSettingsCatalogReadableTest` test file - - File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` - - Setup: Mock GraphClient, create test policy with Settings Catalog type - -- [ ] **T033** Test Settings Catalog policy view shows tabs - - Navigate to Policy View - - Assert: Tabs component present - - Assert: "Settings" and "JSON" tabs visible - - File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` - -- [ ] **T034** Test Settings tab shows display names (not definition IDs) - - Mock: Definitions cached - - Assert: Display names shown in UI - - Assert: Definition IDs NOT visible - - File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` - -- [ ] **T035** Test values formatted correctly - - Mock: Settings with bool, int, string, choice values - - Assert: Bool shows "Enabled"/"Disabled" - - Assert: Int shows formatted number - - Assert: String shows truncated value - - File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` - -- [ ] **T036** [US2] Test search/filter functionality - - Input: Type search query - - Assert: Settings list filtered - - Assert: Only matching settings shown - - Assert: Clear search resets view - - File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` - -- [ ] **T037** Test graceful degradation for missing definitions - - Mock: Definitions not cached - - Assert: Fallback labels shown (prettified IDs) - - Assert: No broken layout - - Assert: Info message visible - - File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` - -### Validation & Polish (T038-T042) - -- [ ] **T038** Run Pest test suite for Feature 185 - - Command: `./vendor/bin/sail artisan test --filter=SettingsCatalog` - - Assert: All tests pass - - Fix any failures - -- [ ] **T039** Run Laravel Pint on modified files - - Command: `./vendor/bin/sail pint --dirty` - - Assert: No style issues - - Commit fixes - -- [ ] **T040** Review git changes for Feature 185 - - Check: No changes to forbidden areas (see constitution) - - Verify: Only expected files modified - - Document: List of changed files in research.md - -- [ ] **T041** Run database migration on local environment - - Command: `./vendor/bin/sail artisan migrate` - - Verify: `settings_catalog_definitions` table created - - Check: Indexes applied correctly - -- [ ] **T042** Manual QA: Policy View with Settings Catalog policy - - Navigate to Policy View for Settings Catalog policy - - Verify: Tabs present ("Settings" and "JSON") - - Verify: Settings tab shows accordion with groups - - Verify: Display names shown (not raw IDs) - - Verify: Values formatted correctly - - Test: Search filters settings - - Test: JSON tab works - - Test: Copy buttons functional - - Test: Dark mode toggle - ---- - -## Dependencies & Execution Order - -### Sequential Dependencies -- **Phase 1** → **Phase 2**: Database must exist before resolver can cache -- **Phase 2** → **Phase 3**: Resolver must exist before snapshot enrichment -- **Phase 2** → **Phase 4**: Definitions needed for normalizer -- **Phase 4** → **Phase 5**: Normalized data structure needed for UI -- **Phase 5** → **Phase 7**: UI must exist before feature tests - -### Parallel Opportunities -- **Phase 2** (T003-T007): Resolver methods can be implemented in parallel -- **Phase 4** (T011-T014): Normalizer sub-methods can be implemented in parallel -- **Phase 5** (T015-T022): UI components can be developed in parallel after T015 -- **Phase 7** (T026-T031): Unit tests can be written in parallel -- **Phase 7** (T032-T037): Feature tests can be written in parallel - -### Example Parallel Execution -**Phase 2**: -- Developer A: T003, T004 (resolve methods) -- Developer B: T005, T006 (resolveOne + fallback) -- Both converge for T007 (warmCache) - -**Phase 5**: -- Developer A: T015-T017 (tabs + accordion setup) -- Developer B: T018-T020 (rendering logic) -- Both converge for T021-T022 (search functionality) - ---- - -## Task Complexity Estimates - -| Phase | Task Count | Estimated Time | Dependencies | -|-------|------------|----------------|--------------| -| Phase 1: Database | 2 | ~30 min | None | -| Phase 2: Resolver | 5 | ~2-3 hours | Phase 1 | -| Phase 3: Snapshot | 3 | ~1 hour | Phase 2 | -| Phase 4: Normalizer | 4 | ~2-3 hours | Phase 2 | -| Phase 5: UI Settings | 8 | ~3-4 hours | Phase 4 | -| Phase 6: UI Tabs | 3 | ~1 hour | Phase 5 | -| Phase 7: Testing | 17 | ~3-4 hours | Phase 2-6 | -| **Total** | **42** | **11-15 hours** | | - ---- - -## Success Criteria Checklist - -- [X] **SC-001**: Admin sees human-readable setting names (not definition IDs) on Policy View (Implementation complete - requires manual verification) -- [X] **SC-002**: Setting values formatted appropriately (True/False, numbers, choice labels) (Implementation complete - requires manual verification) -- [X] **SC-003**: Settings grouped by category with accordion (collapsible sections) (Implementation complete - requires manual verification) -- [X] **SC-004**: Search/filter works on display name and value (<200ms response) (Blade-level implementation complete - requires manual verification) -- [X] **SC-005**: Raw JSON available in separate "JSON" tab (Feature 002 integration preserved) -- [X] **SC-006**: Unknown settings show prettified ID fallback (no broken layout) (Implementation complete - requires manual verification) -- [ ] **SC-007**: Performance: <2s render for policy with 200 settings (Requires load testing) -- [ ] **SC-008**: Tests pass: Unit tests for resolver + normalizer (Tests not written yet) -- [ ] **SC-009**: Tests pass: Feature tests for UI rendering (Tests not written yet) -- [ ] **SC-010**: Definition resolution: <500ms for batch of 50 (cached) (Requires benchmark testing) - ---- - -## Constitution Compliance Evidence - -| Principle | Evidence | Tasks | -|-----------|----------|-------| -| Safety-First | Read-only UI, no edit capabilities | All UI tasks | -| Immutable Versioning | Snapshot enrichment non-blocking, metadata only | T008-T010 | -| Defensive Restore | Not applicable (read-only feature) | N/A | -| Auditability | Raw JSON still accessible via tab | T023-T025 | -| Tenant-Aware | Resolver respects tenant scoping (via GraphClient) | T003-T007 | -| Graph Abstraction | Uses existing GraphClientInterface | T003-T007 | -| Spec-Driven | Full spec + plan + tasks before implementation | This document | - ---- - -## Risk Mitigation Tasks - -- **Risk**: Graph API rate limiting - - **Mitigation**: T007 (warmCache is non-blocking), aggressive DB caching -- **Risk**: Definition schema changes by Microsoft - - **Mitigation**: T001 (raw JSONB storage), T006 (fallback logic) -- **Risk**: Large policies slow UI - - **Mitigation**: T017-T018 (accordion lazy-loading), performance tests in T042 - ---- - -## Notes for Implementation - -1. **Feature 002 Dependency**: Feature 185 uses tabs from Feature 002 JSON viewer implementation. Ensure Feature 002 code is stable before starting Phase 5. - -2. **Database Migration**: Run migration early (T001) to avoid blocking later phases. - -3. **Graph API Endpoints**: Verify access to `/deviceManagement/configurationSettings` endpoint in test environment before implementing T004. - -4. **Testing Strategy**: Write unit tests (Phase 7, T026-T031) in parallel with implementation to enable TDD workflow. - -5. **UI Polish**: Leave time for manual QA (T042) to catch UX issues not covered by automated tests. - -6. **Performance Profiling**: Use Laravel Telescope or Debugbar during T042 to measure actual performance vs NFR targets. - ---- - -## Implementation Readiness - -**Prerequisites**: -- ✅ Feature 002 JSON viewer implemented (tabs pattern established) -- ✅ pepperfm/filament-json installed -- ✅ GraphClientInterface available -- ✅ PolicyNormalizer exists -- ✅ PolicySnapshotService exists -- ✅ PostgreSQL with JSONB support - -**Ready to Start**: Phase 1 (Database Foundation) -- 2.45.2 From 1e66f3e33548928b06557743101081b8873386b6 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 22 Dec 2025 00:20:32 +0100 Subject: [PATCH 02/21] docs: update 001 plan - T179/T185 moved to feature 003 --- specs/001-rbac-onboarding/plan.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/specs/001-rbac-onboarding/plan.md b/specs/001-rbac-onboarding/plan.md index 94f9ab0..dadc63f 100644 --- a/specs/001-rbac-onboarding/plan.md +++ b/specs/001-rbac-onboarding/plan.md @@ -5,7 +5,7 @@ # Implementation Plan: TenantPilot v1 - RBAC Onboarding ## Summary -TenantPilot v1 core flows are already implemented per `specs/001-rbac-onboarding/tasks.md`. This plan focuses on finishing the remaining open items for this branch: US4 restore rerun (T156), optional RBAC check/report CLI (T167), and Settings Catalog improvements (T179, T185, T186). The RBAC onboarding wizard (US7) is tenant scoped, uses delegated login, and applies idempotent RBAC setup with audit logging. All Graph calls stay behind the Graph abstraction and contract registry. +TenantPilot v1 core flows are already implemented per `specs/001-rbac-onboarding/tasks.md`. This plan focuses on finishing the remaining open items for this branch: US4 restore rerun (T156), optional RBAC check/report CLI (T167), and Settings Catalog payload preservation (T186). Settings Catalog hydration and UI improvements (T179, T185) are now handled by Feature 003. The RBAC onboarding wizard (US7) is tenant scoped, uses delegated login, and applies idempotent RBAC setup with audit logging. All Graph calls stay behind the Graph abstraction and contract registry. ## Technical Context @@ -69,8 +69,7 @@ ### Phase B - Restore rerun UX - Implement T156: rerun action clones restore run (backup_set_id, items, dry_run) and enforces same safety gates. ### Phase C - Settings Catalog restore correctness and readability -- Implement T179: central hydration of settingsCatalogPolicy snapshots (versions, backups, previews). -- Implement T185: improve labels/value previews in settings table. +- ✅ T179 & T185: Moved to Feature 003 (settings-catalog-readable) - Implement T186: ensure settings_apply payload preserves @odata.type and correct body shape. ### Testing and Quality Gates -- 2.45.2 From 1fa15b4db23406326073fe7da465989ee3bfa181 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 22 Dec 2025 01:07:03 +0100 Subject: [PATCH 03/21] spec: Feature 004 & 005 with production-tested Graph API strategies 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) --- specs/004-assignments-scope-tags/spec.md | 472 +++++++++++++++++ specs/005-bulk-operations/spec.md | 617 +++++++++++++++++++++++ 2 files changed, 1089 insertions(+) create mode 100644 specs/004-assignments-scope-tags/spec.md create mode 100644 specs/005-bulk-operations/spec.md diff --git a/specs/004-assignments-scope-tags/spec.md b/specs/004-assignments-scope-tags/spec.md new file mode 100644 index 0000000..58c405c --- /dev/null +++ b/specs/004-assignments-scope-tags/spec.md @@ -0,0 +1,472 @@ +# Feature 004: Assignments & Scope Tags for Settings Catalog Policies + +## Overview +Extend backup and restore functionality to include **Assignments** (group/user/device targeting) and **Scope Tags** for Settings Catalog policies. This ensures complete policy state capture and enables cross-tenant migrations with group mapping. + +## Problem Statement +Currently, TenantPilot backs up only the Settings Catalog policy configuration itself. However, **Assignments** (which groups/users/devices the policy applies to) and **Scope Tags** (RBAC-based policy visibility) are critical metadata that make a policy actionable. + +**Without this feature:** +- Restored policies have no assignments → must be manually configured +- Cross-tenant migrations lose targeting context +- Scope Tags are not preserved → RBAC-scoped admins may lose access +- Backup previews don't show assignment counts or scope + +**With this feature:** +- Complete policy state backup (config + assignments + scope tags) +- Cross-tenant restore with intelligent group mapping +- Clear preview/diff of assignment changes +- Optional inclusion (lightweight backups possible) + +## Goals +- **Primary**: Backup and restore assignments for Settings Catalog policies +- **Secondary**: Include Scope Tags in backup metadata +- **Tertiary**: Group mapping wizard for cross-tenant restores +- **Non-Goal**: Assignments for non-Settings Catalog types (future features 006-009) + +## Scope +- **Policy Types**: `settingsCatalogPolicy` only (initially) +- **Graph Endpoints**: + - GET `/deviceManagement/configurationPolicies/{id}/assignments` + - POST/PATCH `/deviceManagement/configurationPolicies/{id}/assign` + - GET `/deviceManagement/roleScopeTags` (for reference data) +- **Backup Behavior**: Optional (checkbox "Include Assignments & Scope Tags") +- **Restore Behavior**: With group mapping UI for unresolved group IDs + +--- + +## User Stories + +### User Story 1 - Backup with Assignments & Scope Tags (Priority: P1) + +**As an admin**, I want to optionally include assignments and scope tags when backing up Settings Catalog policies, so that I have complete policy state for migration or disaster recovery. + +**Acceptance Criteria:** +1. **Given** I create a new Backup Set for Settings Catalog policies, + **When** I enable the checkbox "Include Assignments & Scope Tags", + **Then** the backup captures: + - Assignment list (groups, users, devices with include/exclude mode) + - Scope Tag IDs referenced by the policy + - Metadata about assignment count and scope tag names + +2. **Given** I view a Backup Set with assignments included, + **When** I expand a Backup Item detail, + **Then** I see: + - "Assignments: 3 groups, 2 users" summary + - "Scope Tags: Default, HR-Admins" list + - JSON tab with full assignment payload + +3. **Given** I create a Backup Set without enabling the checkbox, + **When** the backup completes, + **Then** assignments and scope tags are NOT captured (payload-only backup) + +--- + +### User Story 2 - Policy View with Assignments Tab (Priority: P1) + +**As an admin**, I want to see a policy's current assignments and scope tags in the Policy View, so I understand its targeting and visibility. + +**Acceptance Criteria:** +1. **Given** I view a Settings Catalog policy, + **When** I navigate to the "Assignments" tab, + **Then** I see: + - Table with columns: Type (Group/User/Device), Name, Mode (Include/Exclude), ID + - "Scope Tags" section showing: Default, HR-Admins (editable IDs) + - "Not assigned" message if no assignments exist + +2. **Given** a policy has 10 assignments, + **When** I filter by "Include only" or "Exclude only", + **Then** the table filters accordingly + +3. **Given** assignments include deleted groups (orphaned IDs), + **When** I view the assignments tab, + **Then** orphaned entries show as "Unknown Group (ID: abc-123)" with warning badge + +--- + +### User Story 3 - Restore with Group Mapping (Priority: P1) + +**As an admin**, I want to map source tenant groups to target tenant groups during restore, so I can migrate policies across tenants without manual re-assignment. + +**Acceptance Criteria:** +1. **Given** I restore a Backup Item with assignments to a different tenant, + **When** the restore preview detects unresolved group IDs, + **Then** the wizard shows a "Group Mapping" step with: + - Source group name and ID + - Target tenant groups dropdown (searchable) + - "Skip assignment" checkbox per group + +2. **Given** I map 3 source groups to target groups, + **When** I confirm the restore, + **Then** the restored policy has assignments pointing to the mapped target groups + +3. **Given** I choose "Skip assignment" for 2 groups, + **When** the restore completes, + **Then** those 2 assignments are NOT created (only mapped groups restored) + +4. **Given** all source groups exist in target tenant (same IDs), + **When** the restore runs, + **Then** the Group Mapping step is skipped (auto-matched) + +--- + +### User Story 4 - Restore Preview with Assignment Diff (Priority: P2) + +**As an admin**, I want to see assignment changes in the restore preview, so I know what will be modified before executing. + +**Acceptance Criteria:** +1. **Given** I preview a restore that includes assignments, + **When** the target policy has different assignments, + **Then** the preview shows: + - "Assignments: 3 will be added, 1 removed, 2 unchanged" + - Expandable diff: Added (green), Removed (red), Unchanged (gray) + +2. **Given** the target policy has no existing assignments, + **When** I preview the restore, + **Then** the preview shows "Assignments: 5 will be created" + +3. **Given** I restore a backup without assignments, + **When** I preview the restore, + **Then** the assignment section shows "Not included in backup" + +--- + +## Functional Requirements + +### Backup & Storage + +**FR-004.1**: System MUST provide a checkbox "Include Assignments & Scope Tags" on the Backup Set creation form (default: unchecked). + +**FR-004.2**: When assignments are included, system MUST fetch assignments using fallback strategy: +1. Try: `/deviceManagement/configurationPolicies/{id}/assignments` +2. If empty/fails: Try `$expand=assignments` on policy fetch +3. Store: + - Assignment array (each with: `target` object, `id`, `intent`, filters) + - Extracted metadata: group names (resolved via `/directoryObjects/getByIds`), user UPNs, device IDs + - Warning flags for orphaned IDs + - Fallback flag: `assignments_fetch_method` (direct | expand | failed) + +**FR-004.3**: System MUST store Scope Tag IDs in backup metadata (from policy payload `roleScopeTagIds` field). + +**FR-004.4**: Backup Item `metadata` JSONB field MUST include: +```json +{ + "assignment_count": 5, + "scope_tag_ids": ["0", "abc-123"], + "scope_tag_names": ["Default", "HR-Admins"], + "has_orphaned_assignments": false +} +``` + +**FR-004.5**: System MUST gracefully handle Graph API failures when fetching assignments (log warning, continue backup with flag `assignments_fetch_failed: true`). + +### UI Display + +**FR-004.6**: Policy View MUST show an "Assignments" tab for Settings Catalog policies displaying: +- Assignments table (type, name, mode, ID) +- Scope Tags section +- Empty state if no assignments + +**FR-004.7**: Backup Item detail view MUST show assignment count and scope tag names in metadata summary. + +**FR-004.8**: System MUST render orphaned group IDs as "Unknown Group (ID: {id})" with warning icon. + +### Restore with Group Mapping + +**FR-004.9**: Restore preview MUST detect unresolved group IDs by calling POST `/directoryObjects/getByIds` with batch of group IDs extracted from source assignments (types: ["group"]). Missing IDs = unresolved. + +**FR-004.10**: When unresolved groups exist, system MUST inject a "Group Mapping" step in the restore wizard showing: +- Source group (name from backup metadata or ID if name unavailable) +- Target group dropdown (searchable, populated from target tenant) +- "Skip" checkbox + +**FR-004.11**: System MUST persist group mapping selections in RestoreRun metadata for audit and rerun purposes. + +**FR-004.12**: When restoring assignments, system MUST: +1. Replace source group IDs with mapped target group IDs +2. Skip assignments marked "Skip" +3. Preserve include/exclude intent and filters +4. Call POST `/deviceManagement/configurationPolicies/{id}/assign` (not PATCH) with complete mapped assignments array (replaces all assignments atomically) +5. Handle 204 No Content or 200 OK as success +6. Log Graph request-id and client-request-id on failure + +**FR-004.13**: System MUST handle assignment restore failures gracefully: +- Log per-assignment outcome (success/skip/failure) +- Continue with remaining assignments +- Report final status: "3 of 5 assignments restored" + +**FR-004.14**: System MUST write audit log entries: +- `backup.assignments.included` (when checkbox enabled) +- `restore.group_mapping.applied` (with mapping details) +- `restore.assignment.created` (per assignment) +- `restore.assignment.skipped` (per skipped) + +### Scope Tags + +**FR-004.15**: System MUST extract Scope Tag IDs from policy payload's `roleScopeTagIds` array during backup. + +**FR-004.16**: System MUST resolve Scope Tag names via `/deviceManagement/roleScopeTags` (with caching, TTL 1 hour). + +**FR-004.17**: During restore, system SHOULD preserve Scope Tag IDs if they exist in target tenant, or: +- Log warning if Scope Tag ID doesn't exist in target +- Allow policy creation to proceed (Graph API default behavior) + +**FR-004.18**: Restore preview MUST show Scope Tag diff: "Scope Tags: 2 matched, 1 not found in target tenant". + +--- + +## Non-Functional Requirements + +**NFR-004.1**: Assignment fetching MUST NOT block backup creation (async or fail-soft). + +**NFR-004.2**: Group mapping UI MUST support search/filter for tenants with 500+ groups. + +**NFR-004.3**: Assignment restore MUST batch API calls (if Graph supports batch, else sequential with 100ms delay). + +**NFR-004.4**: System MUST cache target tenant group list for 5 minutes during restore wizard session. + +--- + +## Data Model Changes + +### Migration: `backup_items` table extension + +```php +Schema::table('backup_items', function (Blueprint $table) { + $table->json('assignments')->nullable()->after('metadata'); + // stores: [{target:{...}, id, intent, filters}, ...] +}); +``` + +### Migration: `restore_runs` table extension + +```php +Schema::table('restore_runs', function (Blueprint $table) { + $table->json('group_mapping')->nullable()->after('results'); + // stores: {"source-group-id": "target-group-id", ...} +}); +``` + +### `backup_items.metadata` JSONB schema + +```json +{ + "assignment_count": 5, + "scope_tag_ids": ["0", "123"], + "scope_tag_names": ["Default", "HR"], + "has_orphaned_assignments": false, + "assignments_fetch_failed": false +} +``` + +--- + +## Graph API Integration + +### Endpoints to Add (Production-Tested Strategies) + +1. **GET Assignments (with Fallback Strategy)** + - **Primary**: `/deviceManagement/configurationPolicies/{id}/assignments` + - Returns: `{ value: [assignment objects] }` + - Contract: `type_family: [#microsoft.graph.deviceManagementConfigurationPolicyAssignment]` + - **Fallback** (if primary fails/returns empty): + - `/deviceManagement/configurationPolicies?$expand=assignments&$filter=id eq '{id}'` + - Client-side filter to extract assignments + - **Reason**: Known Graph API quirks with assignment expansion on certain template families + +2. **POST** `/deviceManagement/configurationPolicies/{id}/assign` (POST only, NOT PATCH) + - Body: `{ assignments: [assignment objects] }` + - Returns: 204 No Content or 200 OK + - **Note**: This is an action endpoint, replaces entire assignments array + - Example payload: + ```json + { + "assignments": [ + { + "id": "00000000-0000-0000-0000-000000000000", + "target": { + "@odata.type": "#microsoft.graph.groupAssignmentTarget", + "groupId": "abc-123-def" + }, + "intent": "apply" + } + ] + } + ``` + +3. **POST** `/directoryObjects/getByIds` (Stable Group Resolution) + - Body: `{ "ids": ["id1", "id2"], "types": ["group"] }` + - Returns: `{ value: [{ id, displayName, ... }] }` + - **Reason**: More stable than `$filter=id in (...)` which can fail with advanced query requirements + - Example: + ```json + { + "ids": ["abc-123", "def-456"], + "types": ["group"] + } + ``` + +4. **GET** `/deviceManagement/roleScopeTags?$select=id,displayName` + - For Scope Tag resolution (cache 1 hour) + - Scope Tag IDs also available in policy payload's `roleScopeTagIds` array + +### Graph Contract Updates + +Add to `config/graph_contracts.php`: + +```php +'settingsCatalogPolicy' => [ + // ... existing + 'assignments_path' => '/deviceManagement/configurationPolicies/{id}/assignments', + 'assign_method' => 'POST', + 'assign_path' => '/deviceManagement/configurationPolicies/{id}/assign', + 'supports_scope_tags' => true, +], +``` + +--- + +## UI Mockups (Wireframe Descriptions) + +### Policy View - Assignments Tab + +``` +[General] [Settings] [Assignments] [JSON] + +Assignments (5) +┌─────────────────────────────────────────────────┐ +│ Type │ Name │ Mode │ ID │ +├─────────┼───────────────────┼─────────┼─────────┤ +│ Group │ All Users │ Include │ abc-123 │ +│ Group │ Contractors │ Exclude │ def-456 │ +│ User │ john@contoso.com │ Include │ ghi-789 │ +└─────────────────────────────────────────────────┘ + +Scope Tags (2) + • Default (ID: 0) + • HR-Admins (ID: 123) +``` + +### Backup Creation - Checkbox + +``` +Create Backup Set +───────────────── +Select Policies: [Settings Catalog: 15 selected] + +☑ Include Assignments & Scope Tags + Captures group/user targeting and RBAC scope. + Adds ~2-5 KB per policy with assignments. + +[Cancel] [Create Backup] +``` + +### Restore Wizard - Group Mapping Step + +``` +Restore Preview > Group Mapping > Confirm + +Group Mapping Required +Some groups from source tenant don't exist in target tenant. + +┌────────────────────────────────────────────────────────┐ +│ Source Group │ Target Group │ Action │ +├───────────────────────┼───────────────────────┼────────┤ +│ All Users (abc-123) │ [Select target group] │ ☐ Skip │ +│ HR Team (def-456) │ HR Department │ │ +│ Contractors (ghi-789) │ [Select target group] │ ☑ Skip │ +└────────────────────────────────────────────────────────┘ + +[Back] [Continue] +``` + +--- + +## Testing Strategy + +### Unit Tests +- `AssignmentFetcherTest`: Mock Graph responses, test parsing +- `GroupMapperTest`: Test ID resolution, mapping logic +- `ScopeTagResolverTest`: Test caching, name resolution + +### Feature Tests +- `BackupWithAssignmentsTest`: E2E backup creation with checkbox +- `PolicyViewAssignmentsTabTest`: UI rendering, orphaned IDs +- `RestoreGroupMappingTest`: Wizard flow, mapping persistence +- `RestoreAssignmentApplicationTest`: Graph API calls, outcomes + +### Manual QA +- Create backup with/without assignments checkbox +- Restore to same tenant (auto-match groups) +- Restore to different tenant (group mapping wizard) +- Handle orphaned group IDs gracefully + +--- + +## Rollout Plan + +### Phase 1: Backup with Assignments (MVP) +- Add checkbox to Backup form +- Fetch assignments from Graph +- Store in `backup_items.assignments` +- Display in Policy View (read-only) +- **Duration**: ~8-12 hours + +### Phase 2: Restore with Group Mapping +- Add Group Mapping wizard step +- Implement ID resolution +- Apply assignments on restore +- **Duration**: ~12-16 hours + +### Phase 3: Scope Tags +- Resolve Scope Tag names +- Display in UI +- Handle restore warnings +- **Duration**: ~4-6 hours + +### Phase 4: Future Extensions +- Feature 006: Extend to `compliancePolicy` +- Feature 007: Extend to `deviceConfiguration` +- Feature 008: Extend to Conditional Access +- Feature 009: Assignment analytics/reporting + +--- + +## Dependencies +- Feature 001: Backup/Restore core (✅ complete) +- Graph Contract Registry (✅ complete) +- Filament multi-step forms (built-in) + +## Risks & Mitigations + +| Risk | Mitigation | +|------|------------| +| Graph API assignments endpoint slow/fails | Async fetch, fail-soft with warning | +| Target tenant has 1000+ groups | Searchable dropdown with pagination | +| Group IDs change across tenants | Group name-based matching fallback | +| Scope Tag IDs don't exist in target | Log warning, allow policy creation | + +--- + +## Success Criteria + +1. ✅ Backup checkbox functional, assignments captured +2. ✅ Policy View shows assignments tab with accurate data +3. ✅ Group Mapping wizard handles 100+ groups smoothly +4. ✅ Restore applies assignments with 90%+ success rate +5. ✅ Audit logs record all mapping decisions +6. ✅ Tests achieve 85%+ coverage for new code + +--- + +## Open Questions +1. Should we support "smart matching" (group name similarity) for group mapping? +2. How to handle dynamic groups (membership rules) - copy rules or skip? +3. Should Scope Tag warnings block restore or just warn? + +--- + +**Status**: Draft for Review +**Created**: 2025-12-22 +**Author**: AI + Ahmed +**Next Steps**: Review → Plan → Tasks diff --git a/specs/005-bulk-operations/spec.md b/specs/005-bulk-operations/spec.md new file mode 100644 index 0000000..1c3cc37 --- /dev/null +++ b/specs/005-bulk-operations/spec.md @@ -0,0 +1,617 @@ +# 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 -- 2.45.2 From 28f440718adea890346ba20aaaa849e32c9e8a2a Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 22 Dec 2025 01:14:03 +0100 Subject: [PATCH 04/21] fix(spec-004): Use documented CRUD on /assignments instead of undocumented /assign Changes: - Replace POST /assign with standard CRUD operations - Restore strategy: DELETE existing + POST new assignments - Graph Contract: assignments_create/update/delete_path + methods - Handle 201 Created (POST) and 204 No Content (DELETE) - Fail-soft: continue if individual assignment fails Based on: Microsoft Learn Graph API docs + real-world usage patterns --- specs/004-assignments-scope-tags/spec.md | 76 +++++++++++++++--------- 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/specs/004-assignments-scope-tags/spec.md b/specs/004-assignments-scope-tags/spec.md index 58c405c..cca658b 100644 --- a/specs/004-assignments-scope-tags/spec.md +++ b/specs/004-assignments-scope-tags/spec.md @@ -183,12 +183,18 @@ ### Restore with Group Mapping **FR-004.11**: System MUST persist group mapping selections in RestoreRun metadata for audit and rerun purposes. **FR-004.12**: When restoring assignments, system MUST: -1. Replace source group IDs with mapped target group IDs -2. Skip assignments marked "Skip" +1. Replace source group IDs with mapped target group IDs in assignment objects +2. Skip assignments marked "Skip" in group mapping 3. Preserve include/exclude intent and filters -4. Call POST `/deviceManagement/configurationPolicies/{id}/assign` (not PATCH) with complete mapped assignments array (replaces all assignments atomically) -5. Handle 204 No Content or 200 OK as success -6. Log Graph request-id and client-request-id on failure +4. Execute restore via DELETE-then-CREATE pattern: + - Step 1: GET existing assignments from target policy + - Step 2: DELETE each existing assignment (via DELETE `/assignments/{id}`) + - Step 3: POST each new/mapped assignment (via POST `/assignments`) +5. Handle failures gracefully: + - 204 No Content on DELETE = success + - 201 Created on POST = success + - Log request-id/client-request-id on any failure +6. Continue with remaining assignments if one fails (fail-soft) **FR-004.13**: System MUST handle assignment restore failures gracefully: - Log per-assignment outcome (success/skip/failure) @@ -274,25 +280,30 @@ ### Endpoints to Add (Production-Tested Strategies) - Client-side filter to extract assignments - **Reason**: Known Graph API quirks with assignment expansion on certain template families -2. **POST** `/deviceManagement/configurationPolicies/{id}/assign` (POST only, NOT PATCH) - - Body: `{ assignments: [assignment objects] }` - - Returns: 204 No Content or 200 OK - - **Note**: This is an action endpoint, replaces entire assignments array - - Example payload: - ```json - { - "assignments": [ - { - "id": "00000000-0000-0000-0000-000000000000", - "target": { - "@odata.type": "#microsoft.graph.groupAssignmentTarget", - "groupId": "abc-123-def" - }, - "intent": "apply" - } - ] - } - ``` +2. **Assignment CRUD Operations** (Standard Graph Pattern) + + - **POST** `/deviceManagement/configurationPolicies/{id}/assignments` + - Body: Single assignment object + - Returns: 201 Created with assignment object + - Example: + ```json + { + "target": { + "@odata.type": "#microsoft.graph.groupAssignmentTarget", + "groupId": "abc-123-def" + }, + "intent": "apply" + } + ``` + + - **PATCH** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` + - Body: Assignment object (partial update) + - Returns: 200 OK with updated assignment + + - **DELETE** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` + - Returns: 204 No Content + + - **Restore Strategy**: DELETE all existing assignments, then POST new ones (atomic via transaction pattern) 3. **POST** `/directoryObjects/getByIds` (Stable Group Resolution) - Body: `{ "ids": ["id1", "id2"], "types": ["group"] }` @@ -316,11 +327,20 @@ ### Graph Contract Updates ```php 'settingsCatalogPolicy' => [ - // ... existing - 'assignments_path' => '/deviceManagement/configurationPolicies/{id}/assignments', - 'assign_method' => 'POST', - 'assign_path' => '/deviceManagement/configurationPolicies/{id}/assign', + // ... existing config + + // Assignments CRUD (standard Graph pattern) + 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assignments', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + + // Scope Tags 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', // array in policy payload ], ``` -- 2.45.2 From 673fbd6b22be153f3381999195053577a6bc2079 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 22 Dec 2025 01:27:42 +0100 Subject: [PATCH 05/21] spec(005): Add comprehensive implementation plan for Bulk Operations Adds: - plan.md: Technical context, constitution check, phases - research.md: 7 research decisions (progress tracking, chunking, type-to-confirm) - data-model.md: BulkOperationRun model, schema changes, query patterns - quickstart.md: Developer onboarding, testing workflows, debugging Key Decisions: - BulkOperationRun model + Livewire polling for progress - collect()->chunk(10) for memory-efficient processing - Filament form + validation for type-to-confirm - ignored_at flag to prevent sync re-adding deleted policies - Eligibility scopes for safe Policy Version pruning Estimated: 26-34 hours (3 phases for P1/P2 features) Next: /speckit.tasks to generate task breakdown --- .github/agents/copilot-instructions.md | 29 + specs/005-bulk-operations/data-model.md | 711 ++++++++++++++++++++++++ specs/005-bulk-operations/plan.md | 263 +++++++++ specs/005-bulk-operations/quickstart.md | 425 ++++++++++++++ specs/005-bulk-operations/research.md | 547 ++++++++++++++++++ 5 files changed, 1975 insertions(+) create mode 100644 .github/agents/copilot-instructions.md create mode 100644 specs/005-bulk-operations/data-model.md create mode 100644 specs/005-bulk-operations/plan.md create mode 100644 specs/005-bulk-operations/quickstart.md create mode 100644 specs/005-bulk-operations/research.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md new file mode 100644 index 0000000..62118e0 --- /dev/null +++ b/.github/agents/copilot-instructions.md @@ -0,0 +1,29 @@ +# TenantAtlas Development Guidelines + +Auto-generated from all feature plans. Last updated: 2025-12-22 + +## Active Technologies + +- PHP 8.4.15 (feat/005-bulk-operations) + +## Project Structure + +```text +src/ +tests/ +``` + +## Commands + +# Add commands for PHP 8.4.15 + +## Code Style + +PHP 8.4.15: Follow standard conventions + +## Recent Changes + +- feat/005-bulk-operations: Added PHP 8.4.15 + + + diff --git a/specs/005-bulk-operations/data-model.md b/specs/005-bulk-operations/data-model.md new file mode 100644 index 0000000..47003c1 --- /dev/null +++ b/specs/005-bulk-operations/data-model.md @@ -0,0 +1,711 @@ +# Data Model: Feature 005 - Bulk Operations + +**Feature**: Bulk Operations for Resource Management +**Date**: 2025-12-22 + +--- + +## Overview + +This document describes the data model for bulk operations, including new entities, schema changes, relationships, and query patterns. + +--- + +## Entity Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ BulkOperationRun │ +├─────────────────────────────────────────────────────────────┤ +│ id: bigint PK │ +│ tenant_id: bigint FK → Tenant │ +│ user_id: bigint FK → User │ +│ resource: string (policies, policy_versions, etc.) │ +│ action: string (delete, export, prune, etc.) │ +│ status: enum (pending, running, completed, failed, aborted) │ +│ total_items: int │ +│ processed_items: int │ +│ succeeded: int │ +│ failed: int │ +│ skipped: int │ +│ item_ids: jsonb (array of IDs) │ +│ failures: jsonb (array of {id, reason}) │ +│ audit_log_id: bigint FK → AuditLog (nullable) │ +│ created_at: timestamp │ +│ updated_at: timestamp │ +└─────────────────────────────────────────────────────────────┘ + │ │ │ + │ │ └─────────────┐ + │ │ │ + ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Tenant │ │ User │ │ AuditLog │ +└──────────────┘ └──────────────┘ └──────────────┘ + + +┌─────────────────────────────────────────┐ +│ Policy (Extended) │ +├─────────────────────────────────────────┤ +│ id: bigint PK │ +│ tenant_id: bigint FK │ +│ graph_id: string │ +│ name: string │ +│ ... (existing columns) │ +│ deleted_at: timestamp (nullable) │ +│ ignored_at: timestamp (nullable) ← NEW │ +└─────────────────────────────────────────┘ + + +┌─────────────────────────────────────────┐ +│ PolicyVersion (Extended) │ +├─────────────────────────────────────────┤ +│ id: bigint PK │ +│ policy_id: bigint FK → Policy │ +│ is_current: boolean │ +│ created_at: timestamp │ +│ ... (existing columns) │ +├─────────────────────────────────────────┤ +│ Scope: pruneEligible() ← NEW │ +│ WHERE is_current = false │ +│ AND created_at < NOW() - 90 days │ +│ AND NOT IN (backup_items) │ +│ AND NOT IN (restore_runs.metadata) │ +└─────────────────────────────────────────┘ + + +┌─────────────────────────────────────────┐ +│ RestoreRun (Extended) │ +├─────────────────────────────────────────┤ +│ id: bigint PK │ +│ status: enum (pending, running, ...) │ +│ ... (existing columns) │ +├─────────────────────────────────────────┤ +│ Scope: deletable() ← NEW │ +│ WHERE status IN (completed, failed) │ +└─────────────────────────────────────────┘ +``` + +--- + +## Database Schema + +### New Table: bulk_operation_runs + +```sql +CREATE TABLE bulk_operation_runs ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + resource VARCHAR(50) NOT NULL, -- 'policies', 'policy_versions', 'backup_sets', 'restore_runs' + action VARCHAR(50) NOT NULL, -- 'delete', 'export', 'prune', 'archive' + status VARCHAR(20) NOT NULL, -- 'pending', 'running', 'completed', 'failed', 'aborted' + total_items INT NOT NULL, + processed_items INT NOT NULL DEFAULT 0, + succeeded INT NOT NULL DEFAULT 0, + failed INT NOT NULL DEFAULT 0, + skipped INT NOT NULL DEFAULT 0, + item_ids JSONB NOT NULL, -- [1, 2, 3, ...] + failures JSONB, -- [{"id": 1, "reason": "Graph error"}, ...] + audit_log_id BIGINT REFERENCES audit_logs(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_bulk_operation_runs_tenant_resource_status + ON bulk_operation_runs(tenant_id, resource, status); + +CREATE INDEX idx_bulk_operation_runs_user_created + ON bulk_operation_runs(user_id, created_at DESC); + +CREATE INDEX idx_bulk_operation_runs_status + ON bulk_operation_runs(status) + WHERE status IN ('pending', 'running'); +``` + +**Indexes Rationale**: +- Composite (tenant_id, resource, status): Filter by tenant + resource type + status (UI queries) +- (user_id, created_at): User's recent bulk operations +- Partial index on status: Only index running/pending (99% of queries check these) + +--- + +### Schema Change: policies table + +```sql +ALTER TABLE policies +ADD COLUMN ignored_at TIMESTAMP NULL; + +CREATE INDEX idx_policies_ignored_at + ON policies(ignored_at) + WHERE ignored_at IS NOT NULL; +``` + +**Purpose**: Prevent SyncPoliciesJob from re-importing locally deleted policies. + +**Query Pattern**: +```sql +-- Sync job filters +SELECT * FROM policies WHERE ignored_at IS NULL; + +-- Bulk delete sets +UPDATE policies SET ignored_at = NOW() WHERE id IN (...); +``` + +--- + +### Schema Change: policy_versions table + +**No schema changes needed.** Eligibility scope uses existing columns: +- `is_current` (boolean) +- `created_at` (timestamp) +- Foreign keys checked via relationships + +--- + +## Eloquent Models + +### New Model: BulkOperationRun + +```php + 'array', + 'failures' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + // Relationships + + 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); + } + + // Status Helpers + + public function isRunning(): bool + { + return $this->status === 'running'; + } + + public function isComplete(): bool + { + return in_array($this->status, ['completed', 'failed', 'aborted']); + } + + public function isPending(): bool + { + return $this->status === 'pending'; + } + + // Progress Helpers + + public function progressPercentage(): int + { + if ($this->total_items === 0) { + return 0; + } + return (int) round(($this->processed_items / $this->total_items) * 100); + } + + public function summaryText(): string + { + return match ($this->status) { + 'pending' => "Pending: {$this->total_items} items", + 'running' => "Processing... {$this->processed_items}/{$this->total_items}", + 'completed' => "Completed: {$this->succeeded} succeeded, {$this->failed} failed, {$this->skipped} skipped", + 'failed' => "Failed: {$this->failed}/{$this->total_items} errors", + 'aborted' => "Aborted: Too many failures ({$this->failed}/{$this->total_items})", + default => "Unknown status", + }; + } + + public function hasFailures(): bool + { + return $this->failed > 0; + } + + // Scopes + + public function scopeForResource($query, string $resource) + { + return $query->where('resource', $resource); + } + + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + public function scopeRecent($query) + { + return $query->orderBy('created_at', 'desc'); + } + + public function scopeInProgress($query) + { + return $query->whereIn('status', ['pending', 'running']); + } +} +``` + +--- + +### Extended Model: Policy + +```php +whereNull('ignored_at'); + } + + public function scopeIgnored($query) + { + return $query->whereNotNull('ignored_at'); + } + + // NEW: Methods for bulk operations + + public function markIgnored(): void + { + $this->update(['ignored_at' => now()]); + } + + public function unmarkIgnored(): void + { + $this->update(['ignored_at' => null]); + } + + public function isIgnored(): bool + { + return $this->ignored_at !== null; + } +} +``` + +--- + +### Extended Model: PolicyVersion + +```php +hasMany(BackupItem::class, 'policy_version_id'); + } + + // NEW: Scope for eligibility check + + public function scopePruneEligible($query, int $retentionDays = 90) + { + return $query + ->where('is_current', false) + ->where('created_at', '<', now()->subDays($retentionDays)) + ->whereDoesntHave('backupItems') + ->whereNotIn('id', function ($subquery) { + $subquery->select(DB::raw("CAST(metadata->>'policy_version_id' AS INTEGER)")) + ->from('restore_runs') + ->whereNotNull(DB::raw("metadata->>'policy_version_id'")); + }); + } + + // NEW: Check if version is eligible for pruning + + public function isPruneEligible(int $retentionDays = 90): bool + { + if ($this->is_current) { + return false; + } + + if ($this->created_at->diffInDays(now()) < $retentionDays) { + return false; + } + + if ($this->backupItems()->exists()) { + return false; + } + + $referencedInRestoreRuns = DB::table('restore_runs') + ->whereNotNull(DB::raw("metadata->>'policy_version_id'")) + ->where(DB::raw("CAST(metadata->>'policy_version_id' AS INTEGER)"), $this->id) + ->exists(); + + return !$referencedInRestoreRuns; + } + + // NEW: Get reason why version is not eligible + + public function getIneligibilityReason(int $retentionDays = 90): ?string + { + if ($this->is_current) { + return 'Current version'; + } + + if ($this->created_at->diffInDays(now()) < $retentionDays) { + return "Too recent (< {$retentionDays} days)"; + } + + if ($this->backupItems()->exists()) { + return 'Referenced in backup items'; + } + + $referencedInRestoreRuns = DB::table('restore_runs') + ->whereNotNull(DB::raw("metadata->>'policy_version_id'")) + ->where(DB::raw("CAST(metadata->>'policy_version_id' AS INTEGER)"), $this->id) + ->exists(); + + if ($referencedInRestoreRuns) { + return 'Referenced in restore runs'; + } + + return null; // Eligible + } +} +``` + +--- + +### Extended Model: RestoreRun + +```php +whereIn('status', ['completed', 'failed', 'aborted']); + } + + public function scopeNotDeletable($query) + { + return $query->whereNotIn('status', ['completed', 'failed', 'aborted']); + } + + // NEW: Check if restore run can be deleted + + public function isDeletable(): bool + { + return in_array($this->status, ['completed', 'failed', 'aborted']); + } + + public function getNotDeletableReason(): ?string + { + if ($this->isDeletable()) { + return null; + } + + return match ($this->status) { + 'pending' => 'Restore run is pending', + 'running' => 'Restore run is currently running', + default => "Restore run has status: {$this->status}", + }; + } +} +``` + +--- + +## Query Patterns + +### 1. Create Bulk Operation Run + +```php +$run = BulkOperationRun::create([ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'resource' => 'policies', + 'action' => 'delete', + 'status' => 'pending', + 'total_items' => count($policyIds), + 'item_ids' => $policyIds, +]); + +// Dispatch job +BulkPolicyDeleteJob::dispatch($policyIds, $tenantId, $userId, $run->id); + +$run->update(['status' => 'running']); +``` + +### 2. Update Progress (in job) + +```php +$run->update([ + 'processed_items' => $run->processed_items + $chunkSize, + 'succeeded' => $run->succeeded + $successCount, + 'failed' => $run->failed + $failCount, + 'failures' => array_merge($run->failures ?? [], $newFailures), +]); +``` + +### 3. Query Recent Bulk Operations (UI) + +```php +// User's recent operations +$runs = BulkOperationRun::forUser(auth()->id()) + ->recent() + ->limit(10) + ->get(); + +// Tenant's in-progress operations +$inProgress = BulkOperationRun::where('tenant_id', $tenantId) + ->inProgress() + ->get(); +``` + +### 4. Filter Policies (exclude ignored) + +```php +// Sync job +$policies = Policy::where('tenant_id', $tenantId) + ->notIgnored() + ->get(); + +// Bulk delete (mark as ignored) +Policy::whereIn('id', $policyIds) + ->update(['ignored_at' => now()]); +``` + +### 5. Check Policy Version Eligibility + +```php +// Get all eligible versions for tenant +$eligibleVersions = PolicyVersion::whereHas('policy', function ($query) use ($tenantId) { + $query->where('tenant_id', $tenantId); + }) + ->pruneEligible(90) + ->get(); + +// Check single version +$version = PolicyVersion::find($id); +if (!$version->isPruneEligible()) { + $reason = $version->getIneligibilityReason(); + // Skip with reason: "Referenced in backup items" +} +``` + +### 6. Filter Deletable Restore Runs + +```php +// Get deletable runs +$deletableRuns = RestoreRun::where('tenant_id', $tenantId) + ->deletable() + ->get(); + +// Check individual run +$run = RestoreRun::find($id); +if (!$run->isDeletable()) { + $reason = $run->getNotDeletableReason(); + // Skip: "Restore run is currently running" +} +``` + +--- + +## JSONB Structure + +### bulk_operation_runs.item_ids + +```json +[1, 2, 3, 4, 5, ...] +``` + +Simple array of integer IDs. + +### bulk_operation_runs.failures + +```json +[ + { + "id": 123, + "reason": "Graph API error: 503 Service Unavailable" + }, + { + "id": 456, + "reason": "Policy not found" + }, + { + "id": 789, + "reason": "Permission denied" + } +] +``` + +Array of objects with `id` (resource ID) and `reason` (error message). + +### restore_runs.metadata (existing) + +```json +{ + "policy_version_id": 42, + "backup_set_id": 15, + "items_count": 10, + ... +} +``` + +When checking eligibility, query: +```sql +SELECT * FROM restore_runs +WHERE metadata->>'policy_version_id' = '42'; +``` + +--- + +## Migration Files + +### Migration 1: Create bulk_operation_runs table + +```php +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); + $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'); + $table->json('failures')->nullable(); + $table->foreignId('audit_log_id')->nullable()->constrained()->nullOnDelete(); + $table->timestamps(); + + $table->index(['tenant_id', 'resource', 'status']); + $table->index(['user_id', 'created_at']); + $table->index('status')->where('status', 'in', ['pending', 'running']); + }); + } + + public function down(): void + { + Schema::dropIfExists('bulk_operation_runs'); + } +}; +``` + +### Migration 2: Add ignored_at to policies + +```php +timestamp('ignored_at')->nullable()->after('deleted_at'); + $table->index('ignored_at'); + }); + } + + public function down(): void + { + Schema::table('policies', function (Blueprint $table) { + $table->dropIndex(['ignored_at']); + $table->dropColumn('ignored_at'); + }); + } +}; +``` + +--- + +## Data Retention & Cleanup + +### BulkOperationRun Retention + +Recommended: Keep for 90 days, then archive or delete completed runs. + +```php +// Scheduled command +Artisan::command('bulk-operations:prune', function () { + $deleted = BulkOperationRun::where('created_at', '<', now()->subDays(90)) + ->whereIn('status', ['completed', 'failed', 'aborted']) + ->delete(); + + $this->info("Pruned {$deleted} old bulk operation runs"); +})->daily(); +``` + +### PolicyVersion Retention + +Handled by bulk prune feature (user-initiated, not automatic). + +--- + +**Status**: Data Model Complete +**Next Step**: Generate quickstart.md diff --git a/specs/005-bulk-operations/plan.md b/specs/005-bulk-operations/plan.md new file mode 100644 index 0000000..0a79cf8 --- /dev/null +++ b/specs/005-bulk-operations/plan.md @@ -0,0 +1,263 @@ +# Implementation Plan: Feature 005 - Bulk Operations + +**Branch**: `feat/005-bulk-operations` | **Date**: 2025-12-22 | **Spec**: [spec.md](./spec.md) + +## Summary + +Enable efficient bulk operations (delete, export, prune) across TenantPilot's main resources (Policies, Policy Versions, Backup Sets, Restore Runs) with safety gates, progress tracking, and comprehensive audit logging. Technical approach: Filament bulk actions + Laravel Queue jobs with chunked processing + BulkOperationRun tracking model + Livewire polling for progress updates. + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Framework**: Laravel 12 +**Primary Dependencies**: +- Filament v4 (admin panel + bulk actions) +- Livewire v3 (reactive UI + polling) +- Laravel Queue (async job processing) +- PostgreSQL (JSONB for tracking) + +**Storage**: PostgreSQL with JSONB fields for: +- `bulk_operation_runs.item_ids` (array of resource IDs) +- `bulk_operation_runs.failures` (per-item error details) +- Existing audit logs (metadata column) + +**Testing**: Pest v4 (unit, feature, browser tests) +**Target Platform**: Web (Dokploy deployment) +**Project Type**: Web application (Filament admin panel) + +**Performance Goals**: +- Process 100 items in <2 minutes (queued) +- Handle up to 500 items per operation without timeout +- Progress notifications update every 5-10 seconds + +**Constraints**: +- Queue jobs MUST process in chunks of 10-20 items (memory efficiency) +- Progress tracking requires explicit polling (not automatic in Filament) +- Type-to-confirm required for ≥20 destructive items +- Tenant isolation enforced at job level + +**Scale/Scope**: +- 4 primary resources (Policies, PolicyVersions, BackupSets, RestoreRuns) +- 8-12 bulk actions (P1/P2 priority) +- Estimated 26-34 hours implementation (3 phases for P1/P2) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +**Note**: Project constitution is template-only (not populated). Using Laravel/TenantPilot conventions instead. + +### Architecture Principles + +✅ **Library-First**: N/A (feature extends existing app, no new libraries) +✅ **Test-First**: TDD enforced - Pest tests required before implementation +✅ **Simplicity**: Uses existing patterns (Jobs, Filament bulk actions, Livewire polling) +✅ **Sail-First**: Local development uses Laravel Sail (Docker) +✅ **Dokploy Deployment**: Production/staging via Dokploy (VPS containers) + +### Laravel Conventions + +✅ **PSR-12**: Code formatting enforced via Laravel Pint +✅ **Eloquent-First**: No raw DB queries, use Model::query() patterns +✅ **Permission Gates**: Leverage existing RBAC (Feature 001) +✅ **Queue Jobs**: Use ShouldQueue interface, chunked processing +✅ **Audit Logging**: Extend existing AuditLog model/service + +### Safety Requirements + +✅ **Tenant Isolation**: Job constructor accepts explicit `tenantId` +✅ **Audit Trail**: One audit log entry per bulk operation + per-item outcomes +✅ **Confirmation**: Type-to-confirm for ≥20 destructive items +✅ **Fail-Soft**: Continue processing on individual failures, abort if >50% fail +✅ **Immutability**: Policy Versions check eligibility before prune (referenced, current, age) + +### Gates + +🔒 **GATE-01**: Bulk operations MUST use existing permission model (policies.delete, etc.) +🔒 **GATE-02**: Progress tracking MUST use BulkOperationRun model (not fire-and-forget) +🔒 **GATE-03**: Type-to-confirm MUST be case-sensitive "DELETE" for ≥20 items +🔒 **GATE-04**: Policies bulk delete = local only (ignored_at flag, NO Graph DELETE) + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-bulk-operations/ +├── plan.md # This file +├── research.md # Phase 0 output (see below) +├── data-model.md # Phase 1 output (see below) +├── quickstart.md # Phase 1 output (see below) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT YET CREATED) +``` + +### Source Code (repository root) + +```text +app/ +├── Models/ +│ ├── BulkOperationRun.php # NEW: Tracks progress/outcomes +│ ├── Policy.php # EXTEND: Add markIgnored() scope +│ ├── PolicyVersion.php # EXTEND: Add pruneEligible() scope +│ ├── BackupSet.php # EXTEND: Cascade delete logic +│ └── RestoreRun.php # EXTEND: Skip running status +│ +├── Jobs/ +│ ├── BulkPolicyDeleteJob.php # NEW: Async bulk delete (local) +│ ├── BulkPolicyExportJob.php # NEW: Export to backup set +│ ├── BulkPolicyVersionPruneJob.php # NEW: Prune old versions +│ ├── BulkBackupSetDeleteJob.php # NEW: Delete backup sets +│ └── BulkRestoreRunDeleteJob.php # NEW: Delete restore runs +│ +├── Services/ +│ ├── BulkOperationService.php # NEW: Orchestrates bulk ops + tracking +│ └── Audit/ +│ └── AuditLogger.php # EXTEND: Add bulk operation events +│ +├── Filament/ +│ └── Resources/ +│ ├── PolicyResource.php # EXTEND: Add bulk actions +│ ├── PolicyVersionResource.php # EXTEND: Add bulk prune +│ ├── BackupSetResource.php # EXTEND: Add bulk delete +│ └── RestoreRunResource.php # EXTEND: Add bulk delete +│ +└── Livewire/ + └── BulkOperationProgress.php # NEW: Progress polling component + +database/ +└── migrations/ + └── YYYY_MM_DD_create_bulk_operation_runs_table.php # NEW + +tests/ +├── Unit/ +│ ├── BulkPolicyDeleteJobTest.php +│ ├── BulkActionPermissionTest.php +│ └── BulkEligibilityCheckTest.php +│ +└── Feature/ + ├── BulkDeletePoliciesTest.php + ├── BulkExportToBackupTest.php + ├── BulkProgressNotificationTest.php + └── BulkTypeToConfirmTest.php +``` + +**Structure Decision**: Single web application structure (Laravel + Filament). New bulk operations extend existing Resources with BulkAction definitions. New BulkOperationRun model tracks async job progress. No separate API layer needed (Livewire polling uses Filament infolists/resource pages). + +## Complexity Tracking + +> No constitution violations requiring justification. + +--- + +## Phase 0: Research & Technology Decisions + +See [research.md](./research.md) for detailed research findings. + +### Key Decisions Summary + +| Decision | Chosen | Rationale | +|----------|--------|-----------| +| Progress tracking | BulkOperationRun model + Livewire polling | Explicit state, survives page refresh, queryable outcomes | +| Job chunking | collect()->chunk(10) | Simple, memory-efficient, easy to test | +| Type-to-confirm | Filament form + validation rule | Built-in UI, reusable pattern | +| Tenant isolation | Explicit tenantId param | Fail-safe, auditable, no reliance on global scopes | +| Policy deletion | ignored_at flag | Prevents re-sync, restorable, doesn't touch Intune | +| Eligibility checks | Eloquent scopes | Reusable, testable, composable | + +--- + +## Phase 1: Data Model & Contracts + +See [data-model.md](./data-model.md) for detailed schemas and entity diagrams. + +### Core Entities + +**BulkOperationRun** (NEW): +- Tracks progress, outcomes, failures for bulk operations +- Fields: resource, action, status, total_items, processed_items, succeeded, failed, skipped +- JSONB: item_ids, failures +- Relationships: tenant, user, auditLog + +**Policy** (EXTEND): +- Add `ignored_at` timestamp (prevents re-sync) +- Add `markIgnored()` method and `notIgnored()` scope + +**PolicyVersion** (EXTEND): +- Add `pruneEligible()` scope (checks age, references, current status) + +**RestoreRun** (EXTEND): +- Add `deletable()` scope (filters by completed/failed status) + +--- + +## Phase 2: Implementation Tasks + +Detailed tasks will be generated via `/speckit.tasks` command. High-level phases: + +### Phase 2.1: Foundation (P1 - Policies) - 8-12 hours +- BulkOperationRun migration + model +- Policies: ignored_at column, bulk delete/export jobs +- Filament bulk actions + type-to-confirm +- BulkOperationService orchestration +- Tests (unit, feature) + +### Phase 2.2: Progress Tracking (P1) - 8-10 hours +- Livewire progress component +- Job progress updates (chunked) +- Circuit breaker (>50% fail abort) +- Audit logging integration +- Tests (progress, polling, audit) + +### Phase 2.3: Additional Resources (P2) - 6-8 hours +- PolicyVersion prune (eligibility scope) +- BackupSet bulk delete +- RestoreRun bulk delete +- Resource extensions +- Tests for each resource + +### Phase 2.4: Polish & Deployment - 4-6 hours +- Manual QA (type-to-confirm, progress UI) +- Load testing (500 items) +- Documentation updates +- Staging → Production deployment + +--- + +## Risk Mitigation + +| Risk | Mitigation | +|------|------------| +| Queue timeouts | Chunk processing (10-20 items), timeout config (300s), circuit breaker | +| Progress polling overhead | Limit interval (5s), index queries, cache recent runs | +| Accidental deletes | Type-to-confirm ≥20 items, `ignored_at` flag (restorable), audit trail | +| Job crashes | Fail-soft, BulkOperationRun status tracking, Laravel retry | +| Eligibility misses | Conservative JSONB queries, manual review before hard delete | +| Sync re-adds policies | `ignored_at` filter in SyncPoliciesJob | + +--- + +## Success Criteria + +- ✅ Bulk delete 100 policies in <2 minutes +- ✅ Type-to-confirm prevents accidents (≥20 items) +- ✅ Progress updates every 5-10s +- ✅ Audit log captures per-item outcomes +- ✅ 95%+ operation success rate +- ✅ All P1/P2 tests pass + +--- + +## Next Steps + +1. ✅ Generate plan.md (this file) +2. → Generate research.md (detailed technology findings) +3. → Generate data-model.md (schemas + diagrams) +4. → Generate quickstart.md (developer onboarding) +5. → Run `/speckit.tasks` to create task breakdown +6. → Begin Phase 2.1 implementation + +--- + +**Status**: Plan Complete - Ready for Research +**Created**: 2025-12-22 +**Last Updated**: 2025-12-22 diff --git a/specs/005-bulk-operations/quickstart.md b/specs/005-bulk-operations/quickstart.md new file mode 100644 index 0000000..000c74f --- /dev/null +++ b/specs/005-bulk-operations/quickstart.md @@ -0,0 +1,425 @@ +# Quickstart: Feature 005 - Bulk Operations + +**Feature**: Bulk Operations for Resource Management +**Date**: 2025-12-22 + +--- + +## Overview + +This quickstart guide helps developers get up and running with Feature 005 (Bulk Operations) for local development, testing, and debugging. + +--- + +## Prerequisites + +- Laravel Sail installed and running +- Composer dependencies installed +- NPM dependencies installed +- Database migrated +- At least one Tenant configured +- Sample Policies, PolicyVersions, BackupSets, RestoreRuns seeded + +--- + +## Local Development Setup + +### 1. Start Sail + +```bash +cd /path/to/TenantAtlas +./vendor/bin/sail up -d +``` + +### 2. Run Migrations + +```bash +./vendor/bin/sail artisan migrate +``` + +This creates: +- `bulk_operation_runs` table +- `ignored_at` column on `policies` table + +### 3. Seed Test Data (Optional) + +```bash +./vendor/bin/sail artisan db:seed --class=BulkOperationsTestSeeder +``` + +Creates: +- 100 test policies +- 50 policy versions (some old, some referenced) +- 10 backup sets +- 20 restore runs (mixed statuses) + +### 4. Start Queue Worker + +Bulk operations require queue processing: + +```bash +./vendor/bin/sail artisan queue:work --tries=3 --timeout=300 +``` + +Or run in background with Supervisor (production): + +```bash +./vendor/bin/sail artisan queue:restart +``` + +### 5. Access Filament Panel + +```bash +open http://localhost/admin +``` + +Navigate to: +- **Policies** → Select multiple → Bulk Actions dropdown +- **Policy Versions** → Bulk Prune +- **Backup Sets** → Bulk Delete +- **Restore Runs** → Bulk Delete + +--- + +## Running Tests + +### Unit Tests + +Test individual components (jobs, scopes, helpers): + +```bash +./vendor/bin/sail artisan test tests/Unit/BulkPolicyDeleteJobTest.php +./vendor/bin/sail artisan test tests/Unit/BulkActionPermissionTest.php +./vendor/bin/sail artisan test tests/Unit/BulkEligibilityCheckTest.php +``` + +### Feature Tests + +Test E2E flows (UI → job → database): + +```bash +./vendor/bin/sail artisan test tests/Feature/BulkDeletePoliciesTest.php +./vendor/bin/sail artisan test tests/Feature/BulkExportToBackupTest.php +./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php +./vendor/bin/sail artisan test tests/Feature/BulkTypeToConfirmTest.php +``` + +### All Tests + +```bash +./vendor/bin/sail artisan test --filter=Bulk +``` + +### Browser Tests (Pest v4) + +Test UI interactions: + +```bash +./vendor/bin/sail artisan test tests/Browser/BulkOperationsTest.php +``` + +--- + +## Manual Testing Workflow + +### Scenario 1: Bulk Delete Policies (< 20 items) + +1. **Navigate**: Admin → Policies +2. **Select**: Check 10 policies +3. **Action**: Click "Delete" in bulk actions dropdown +4. **Confirm**: Modal appears: "Delete 10 policies?" +5. **Submit**: Click "Confirm" +6. **Verify**: + - Success notification: "Deleted 10 policies" + - Policies have `ignored_at` timestamp set + - Policies still exist in Intune (no Graph DELETE call) + - Audit log entry created + +### Scenario 2: Bulk Delete Policies (≥ 20 items, queued) + +1. **Navigate**: Admin → Policies +2. **Select**: Check 25 policies +3. **Action**: Click "Delete" +4. **Confirm**: Modal requires typing "DELETE" +5. **Type**: Enter "DELETE" (case-sensitive) +6. **Submit**: Click "Confirm" +7. **Verify**: + - Job dispatched to queue + - Progress notification: "Deleting policies... 0/25" + - Notification updates every 5s: "Deleting... 10/25", "20/25" + - Final notification: "Deleted 25 policies" + - `BulkOperationRun` record created with status `completed` + - Audit log entry + +### Scenario 3: Bulk Export to Backup + +1. **Navigate**: Admin → Policies +2. **Select**: Check 30 policies +3. **Action**: Click "Export to Backup" +4. **Form**: + - Backup Set Name: "Production Snapshot" + - Include Assignments: ☑ (if Feature 004 implemented) +5. **Submit**: Click "Confirm" +6. **Verify**: + - Job dispatched + - Progress: "Backing up... 10/30" + - Final: "Backup Set 'Production Snapshot' created (30 items)" + - New `BackupSet` record + - 30 `BackupItem` records + - Audit log entry + +### Scenario 4: Bulk Prune Policy Versions + +1. **Navigate**: Admin → Policy Versions +2. **Filter**: Show only non-current versions older than 90 days +3. **Select**: Check 15 versions +4. **Action**: Click "Delete" +5. **Confirm**: Type "DELETE" +6. **Submit**: Click "Confirm" +7. **Verify**: + - Eligibility check runs + - Eligible versions deleted (hard delete) + - Ineligible versions skipped + - Notification: "Deleted 12 policy versions (3 skipped)" + - Failures array shows skip reasons: + - "Referenced in Backup Set #5" + - "Current version" + - "Too recent (< 90 days)" + +### Scenario 5: Circuit Breaker (abort on >50% fail) + +1. **Setup**: Mock Graph API to fail for 60% of items +2. **Navigate**: Admin → Policies +3. **Select**: Check 100 policies +4. **Action**: Bulk Delete +5. **Verify**: + - Job processes ~50 items + - Detects >50% failure rate + - Aborts remaining items + - Notification: "Bulk operation aborted: 55/100 failures exceeded threshold" + - `BulkOperationRun.status` = `aborted` + +--- + +## Debugging + +### View Queue Jobs + +```bash +# List failed jobs +./vendor/bin/sail artisan queue:failed + +# Retry failed job +./vendor/bin/sail artisan queue:retry + +# Flush failed jobs +./vendor/bin/sail artisan queue:flush +``` + +### Inspect BulkOperationRun Records + +```bash +./vendor/bin/sail tinker +``` + +```php +// Get recent runs +$runs = \App\Models\BulkOperationRun::recent()->limit(10)->get(); + +// View failures +$run = \App\Models\BulkOperationRun::find(1); +dd($run->failures); + +// Check progress +echo "{$run->processed_items}/{$run->total_items} ({$run->progressPercentage()}%)"; +``` + +### View Audit Logs + +```bash +# Via Filament UI +# Navigate to: Admin → Audit Logs → Filter by event: "policies.bulk_deleted" + +# Via Tinker +$logs = \App\Models\AuditLog::where('event', 'policies.bulk_deleted')->get(); +``` + +### Database Queries + +```bash +./vendor/bin/sail artisan tinker +``` + +```php +// Policies marked as ignored +$ignored = \App\Models\Policy::ignored()->count(); + +// Policy versions eligible for pruning +$eligible = \App\Models\PolicyVersion::pruneEligible(90)->count(); + +// Deletable restore runs +$deletable = \App\Models\RestoreRun::deletable()->count(); +``` + +### Test Queue Job Manually + +```bash +./vendor/bin/sail artisan tinker +``` + +```php +use App\Jobs\BulkPolicyDeleteJob; +use App\Models\BulkOperationRun; + +$policyIds = [1, 2, 3]; +$tenantId = 1; +$userId = 1; + +$run = BulkOperationRun::create([ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'resource' => 'policies', + 'action' => 'delete', + 'status' => 'pending', + 'total_items' => count($policyIds), + 'item_ids' => $policyIds, +]); + +// Dispatch synchronously for debugging +BulkPolicyDeleteJob::dispatchSync($policyIds, $tenantId, $userId, $run->id); + +// Check result +$run->refresh(); +echo $run->summaryText(); +``` + +--- + +## Common Issues & Solutions + +### Issue 1: Type-to-confirm not working + +**Symptom**: Confirm button remains enabled without typing "DELETE" + +**Solution**: Check Filament form validation rule: +```php +->rule('in:DELETE') // Case-sensitive +``` + +### Issue 2: Progress notifications don't update + +**Symptom**: Progress stuck at "0/100" + +**Solution**: +- Ensure queue worker is running: `./vendor/bin/sail artisan queue:work` +- Check Livewire polling: `wire:poll.5s="refresh"` +- Verify BulkOperationRun is updated in job + +### Issue 3: Policies reappear after deletion + +**Symptom**: Deleted policies show up again after sync + +**Solution**: +- Check `ignored_at` is set: `Policy::find($id)->ignored_at` +- Verify SyncPoliciesJob filters: `->whereNull('ignored_at')` + +### Issue 4: Circuit breaker not aborting + +**Symptom**: Job continues despite >50% failures + +**Solution**: Check circuit breaker logic in job: +```php +if ($results['failed'] > count($this->policyIds) * 0.5) { + $run->update(['status' => 'aborted']); + throw new \Exception('Bulk operation aborted: >50% failure rate'); +} +``` + +### Issue 5: Policy versions deleted despite references + +**Symptom**: Referenced versions are deleted + +**Solution**: Verify eligibility scope includes: +```php +->whereDoesntHave('backupItems') +->whereNotIn('id', function ($subquery) { + $subquery->select(DB::raw("CAST(metadata->>'policy_version_id' AS INTEGER)")) + ->from('restore_runs') + ->whereNotNull(DB::raw("metadata->>'policy_version_id'")); +}); +``` + +--- + +## Performance Benchmarks + +Expected performance (local Sail environment): + +| Operation | Item Count | Duration | Notes | +|-----------|------------|----------|-------| +| Bulk Delete (sync) | 10 | <1s | Immediate feedback | +| Bulk Delete (queued) | 100 | <2min | Chunked, progress updates | +| Bulk Export | 50 | <3min | Includes Graph API calls | +| Bulk Prune | 30 | <30s | Eligibility checks | +| Progress Update | - | 5s | Polling interval | + +--- + +## Code Formatting + +Before committing: + +```bash +./vendor/bin/sail composer pint +``` + +Formats all PHP files per PSR-12. + +--- + +## Next Steps + +1. ✅ Complete Phase 2.1 (Foundation) tasks +2. ✅ Run all tests: `./vendor/bin/sail artisan test --filter=Bulk` +3. ✅ Manual QA: Follow scenarios above +4. ✅ Code review: Check PSR-12, permissions, audit logs +5. ✅ Load testing: Bulk delete 500 items +6. → Deploy to staging +7. → Manual QA on staging +8. → Deploy to production + +--- + +## Useful Commands + +```bash +# Watch queue jobs in real-time +./vendor/bin/sail artisan queue:work --verbose + +# Monitor bulk operations +./vendor/bin/sail artisan tinker +>>> BulkOperationRun::inProgress()->get() + +# Seed more test data +./vendor/bin/sail artisan db:seed --class=BulkOperationsTestSeeder + +# Clear cache +./vendor/bin/sail artisan optimize:clear + +# Restart queue workers (after code changes) +./vendor/bin/sail artisan queue:restart +``` + +--- + +## Resources + +- [Laravel Queue Documentation](https://laravel.com/docs/12.x/queues) +- [Filament Bulk Actions](https://filamentphp.com/docs/4.x/tables/actions#bulk-actions) +- [Livewire Polling](https://livewire.laravel.com/docs/polling) +- [Pest Testing](https://pestphp.com/docs) + +--- + +**Status**: Quickstart Complete +**Next Step**: Update agent context with new learnings diff --git a/specs/005-bulk-operations/research.md b/specs/005-bulk-operations/research.md new file mode 100644 index 0000000..78c0bc4 --- /dev/null +++ b/specs/005-bulk-operations/research.md @@ -0,0 +1,547 @@ +# Research: Feature 005 - Bulk Operations + +**Feature**: Bulk Operations for Resource Management +**Date**: 2025-12-22 +**Research Phase**: Technology Decisions & Best Practices + +--- + +## Research Questions & Findings + +### Q1: How to implement type-to-confirm in Filament bulk actions? + +**Research Goal**: Find a Laravel/Filament-idiomatic way to require explicit confirmation for destructive bulk operations (≥20 items). + +**Findings**: + +Filament BulkActions support conditional forms via `->form()` method: + +```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.') + ] + : [] + ) + ->action(fn (Collection $records, array $data) => { + // Validation ensures $data['confirm_delete'] === 'DELETE' + // Proceed with bulk delete + }); +``` + +**Key Insight**: Filament's form validation automatically prevents submission if `confirm_delete` doesn't match "DELETE" (case-sensitive). + +**Alternatives Considered**: +- Custom modal component (more code, less reusable) +- JavaScript validation (client-side only, less secure) +- Laravel form request (breaks Filament UX flow) + +**Decision**: Use Filament `->form()` with validation rule. + +--- + +### Q2: How to track progress for queued bulk jobs? + +**Research Goal**: Enable real-time progress tracking for async bulk operations (≥20 items) without blocking UI. + +**Findings**: + +Filament notifications are not reactive by default. Must implement custom progress tracking: + +1. **Create BulkOperationRun model** to persist state: + ```php + Schema::create('bulk_operation_runs', function (Blueprint $table) { + $table->id(); + $table->string('status'); // 'pending', '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->json('item_ids'); + $table->json('failures')->nullable(); + // ... tenant_id, user_id, resource, action + }); + ``` + +2. **Job updates model after each chunk**: + ```php + collect($this->policyIds)->chunk(10)->each(function ($chunk) use ($run) { + foreach ($chunk as $id) { + // Process item + } + $run->update([ + 'processed_items' => $run->processed_items + $chunk->count(), + // ... succeeded, failed counts + ]); + }); + ``` + +3. **UI polls for updates** via Livewire: + ```blade +
+ Processing... {{ $run->processed_items }}/{{ $run->total_items }} +
+ ``` + +**Alternatives Considered**: +- **Bus::batch()**: Laravel's batch system tracks job progress, but adds complexity: + - Requires job_batches table (already exists in Laravel) + - Each item becomes separate job (overhead for small batches) + - Good for parallelization, overkill for sequential processing + - Decision: **Not needed** - our jobs process items sequentially with chunking + +- **Filament Pulse**: Real-time application monitoring tool + - Too heavy for single-feature progress tracking + - Requires separate service + - Decision: **Rejected** - use custom BulkOperationRun model + +- **Pusher/WebSockets**: Real-time push notifications + - Infrastructure overhead (Pusher subscription or custom WS server) + - Not needed for 5-10s polling interval + - Decision: **Rejected** - Livewire polling sufficient + +**Decision**: BulkOperationRun model + Livewire polling (5s interval). + +--- + +### Q3: How to handle chunked processing in queue jobs? + +**Research Goal**: Process large batches (up to 500 items) without memory exhaustion or timeout. + +**Findings**: + +Laravel Collections provide `->chunk()` method for memory-efficient iteration: + +```php +collect($this->policyIds)->chunk(10)->each(function ($chunk) use (&$results, $run) { + foreach ($chunk as $id) { + try { + // Process item + $results['succeeded']++; + } catch (\Exception $e) { + $results['failed']++; + $results['failures'][] = ['id' => $id, 'reason' => $e->getMessage()]; + } + } + + // Update progress after each chunk (not per-item) + $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'); + } +}); +``` + +**Key Insights**: +- Chunk size: 10-20 items (balance between DB updates and progress granularity) +- Update BulkOperationRun **after each chunk**, not per-item (reduces DB load) +- Circuit breaker: abort if >50% failures detected mid-process +- Fail-soft: continue processing remaining items on individual failures + +**Alternatives Considered**: +- **Cursor-based chunking**: `Model::chunk(100, function)` + - Good for processing entire tables + - Not needed - we have explicit ID list + +- **Bus::batch()**: Parallel job processing + - Good for independent tasks (e.g., sending emails) + - Our tasks are sequential (delete one, then next) + - Adds complexity without benefit + +- **Database transactions per chunk**: + - Risk: partial failure leaves incomplete state + - Decision: **No transactions** - each item is atomic, fail-soft is intentional + +**Decision**: `collect()->chunk(10)` with after-chunk progress updates. + +--- + +### Q4: How to enforce tenant isolation in bulk jobs? + +**Research Goal**: Ensure bulk operations cannot cross tenant boundaries (critical security requirement). + +**Findings**: + +Laravel Queue jobs serialize model instances poorly (especially Collections). Best practice: + +```php +class BulkPolicyDeleteJob implements ShouldQueue +{ + public function __construct( + public array $policyIds, // array, NOT Collection + public int $tenantId, // explicit tenant ID + public int $actorId, // user ID for audit + public int $bulkOperationRunId // FK to tracking model + ) {} + + public function handle(PolicyRepository $policies): void + { + // Verify all policies belong to tenant (defensive check) + $count = Policy::whereIn('id', $this->policyIds) + ->where('tenant_id', $this->tenantId) + ->count(); + + if ($count !== count($this->policyIds)) { + throw new \Exception('Tenant isolation violation detected'); + } + + // Proceed with bulk operation... + } +} +``` + +**Key Insights**: +- Serialize IDs as `array`, not `Collection` (Collections don't serialize well) +- Pass explicit `tenantId` parameter (don't rely on global scopes) +- Defensive check in job: verify all IDs belong to tenant before processing +- Audit log records `tenantId` and `actorId` for compliance + +**Alternatives Considered**: +- **Global tenant scope**: Rely on Laravel's global scope filtering + - Risk: scope could be disabled/bypassed in job context + - Less explicit, harder to debug + - Decision: **Rejected** - explicit is safer + +- **Pass User model**: `public User $user` + - Serializes entire user object (inefficient) + - User could be deleted before job runs + - Decision: **Rejected** - use `actorId` integer + +**Decision**: Explicit `tenantId` + defensive validation in job. + +--- + +### Q5: How to prevent sync from re-adding "deleted" policies? + +**Research Goal**: User bulk-deletes 50 policies locally, but doesn't want to delete them in Intune. How to prevent SyncPoliciesJob from re-importing them? + +**Findings**: + +Add `ignored_at` timestamp column to policies table: + +```php +// Migration +Schema::table('policies', function (Blueprint $table) { + $table->timestamp('ignored_at')->nullable()->after('deleted_at'); + $table->index('ignored_at'); // query optimization +}); + +// Policy model +public function scopeNotIgnored($query) +{ + return $query->whereNull('ignored_at'); +} + +public function markIgnored(): void +{ + $this->update(['ignored_at' => now()]); +} +``` + +**Modify SyncPoliciesJob**: + +```php +// Before: fetched all policies from Graph, upserted to DB +// After: skip policies where ignored_at IS NOT NULL + +public function handle(PolicySyncService $service): void +{ + $graphPolicies = $service->fetchFromGraph($this->types); + + foreach ($graphPolicies as $graphPolicy) { + $existing = Policy::where('graph_id', $graphPolicy['id']) + ->where('tenant_id', $this->tenantId) + ->first(); + + // Skip if locally ignored + if ($existing && $existing->ignored_at !== null) { + continue; + } + + // Upsert policy... + } +} +``` + +**Key Insight**: `ignored_at` decouples local tracking from Intune state. User can: +- Keep policy in Intune (not deleted remotely) +- Hide policy in TenantPilot (ignored_at set) +- Restore policy later (clear ignored_at) + +**Alternatives Considered**: +- **Soft delete only** (`deleted_at`): + - Problem: Sync doesn't know if user deleted locally or Intune deleted remotely + - Would need separate "deletion source" column + - Decision: **Rejected** - `ignored_at` is clearer intent + +- **Separate "sync_ignore" column**: + - Same outcome as `ignored_at`, but less semantic + - Decision: **Accepted as alias** - `ignored_at` is more descriptive + +**Decision**: Add `ignored_at` timestamp, filter in SyncPoliciesJob. + +--- + +### Q6: How to determine eligibility for Policy Version pruning? + +**Research Goal**: Implement safe "bulk delete old policy versions" that won't break backups/restores. + +**Findings**: + +Eligibility criteria (all must be true): +1. `is_current = false` (not the latest version) +2. `created_at < NOW() - 90 days` (configurable retention period) +3. NOT referenced in `backup_items.policy_version_id` (foreign key check) +4. NOT referenced in `restore_runs.metadata->policy_version_id` (JSONB check) + +Implementation via Eloquent scope: + +```php +// app/Models/PolicyVersion.php +public function scopePruneEligible($query, int $retentionDays = 90) +{ + return $query + ->where('is_current', false) + ->where('created_at', '<', now()->subDays($retentionDays)) + ->whereDoesntHave('backupItems') // FK relationship + ->whereNotIn('id', function ($subquery) { + $subquery->select(DB::raw("CAST(metadata->>'policy_version_id' AS INTEGER)")) + ->from('restore_runs') + ->whereNotNull(DB::raw("metadata->>'policy_version_id'")); + }); +} +``` + +**Bulk prune job**: + +```php +public function handle(): void +{ + foreach ($this->versionIds as $id) { + $version = PolicyVersion::find($id); + + if (!$version) { + $this->failures[] = ['id' => $id, 'reason' => 'Not found']; + continue; + } + + // Check eligibility + $eligible = PolicyVersion::pruneEligible() + ->where('id', $id) + ->exists(); + + if (!$eligible) { + $this->skipped++; + $this->failures[] = ['id' => $id, 'reason' => 'Referenced or too recent']; + continue; + } + + $version->delete(); // hard delete + $this->succeeded++; + } +} +``` + +**Key Insight**: Conservative eligibility check prevents accidental data loss. User sees which versions were skipped and why. + +**Alternatives Considered**: +- **Soft delete first, hard delete later**: Adds complexity, no clear benefit +- **Skip JSONB check**: Risk of breaking restore runs that reference version +- **Admin override**: Allow force-delete even if referenced + - Too dangerous, conflicts with immutability principle + - Decision: **Rejected** + +**Decision**: Eloquent scope `pruneEligible()` with strict checks. + +--- + +### Q7: How to display progress notifications in Filament? + +**Research Goal**: Show real-time progress for bulk operations without blocking UI. + +**Findings**: + +Filament notifications are sent once and don't auto-update. For progress tracking: + +**Option 1: Custom Livewire Component** + +```blade +{{-- resources/views/livewire/bulk-operation-progress.blade.php --}} +
+ @if($run && !$run->isComplete()) +
+

{{ $run->action }} in progress...

+
+
+
+

{{ $run->processed_items }}/{{ $run->total_items }} items processed

+
+ @elseif($run && $run->isComplete()) +
+

✅ {{ $run->summaryText() }}

+ @if($run->failed > 0) + View details + @endif +
+ @endif +
+``` + +```php +// app/Livewire/BulkOperationProgress.php +class BulkOperationProgress extends Component +{ + public int $bulkOperationRunId; + public ?BulkOperationRun $run = null; + + public function mount(int $bulkOperationRunId): void + { + $this->bulkOperationRunId = $bulkOperationRunId; + $this->refresh(); + } + + public function refresh(): void + { + $this->run = BulkOperationRun::find($this->bulkOperationRunId); + + // Stop polling if complete + if ($this->run && $this->run->isComplete()) { + $this->dispatch('bulkOperationComplete', runId: $this->run->id); + } + } + + public function render(): View + { + return view('livewire.bulk-operation-progress'); + } +} +``` + +**Option 2: Filament Infolist Widget** (simpler, more integrated) + +```php +// Display in BulkOperationRun resource ViewRecord page +public static function form(Form $form): Form +{ + return $form + ->schema([ + Infolists\Components\Section::make('Progress') + ->schema([ + Infolists\Components\TextEntry::make('summaryText') + ->label('Status'), + Infolists\Components\ViewEntry::make('progress') + ->view('filament.components.progress-bar') + ->state(fn ($record) => [ + 'percentage' => $record->progressPercentage(), + 'processed' => $record->processed_items, + 'total' => $record->total_items, + ]), + ]) + ->poll('5s') // Filament's built-in polling + ->hidden(fn ($record) => $record->isComplete()), + ]); +} +``` + +**Decision**: Use **Option 1** (custom Livewire component) for flexibility. Embed in: +- Filament notification body (custom view) +- Resource page sidebar +- Dashboard widget (if user wants to monitor all bulk operations) + +**Alternatives Considered**: +- **Pusher/WebSockets**: Too complex for 5s polling +- **JavaScript polling**: Less Laravel-way, harder to test +- **Filament Pulse**: Overkill for single feature + +--- + +## Technology Stack Summary + +| Component | Technology | Justification | +|-----------|------------|---------------| +| Admin Panel | Filament v4 | Built-in bulk actions, forms, notifications | +| Reactive UI | Livewire v3 | Polling, state management, no JS framework needed | +| Queue System | Laravel Queue | Async job processing, retry, failure handling | +| Progress Tracking | BulkOperationRun model + Livewire polling | Persistent state, survives refresh, queryable | +| Type-to-Confirm | Filament form validation | Built-in UI, secure, reusable | +| Tenant Isolation | Explicit tenantId param | Fail-safe, auditable, no implicit scopes | +| Job Chunking | Collection::chunk(10) | Memory-efficient, simple, testable | +| Eligibility Checks | Eloquent scopes | Reusable, composable, database-level filtering | +| Database | PostgreSQL + JSONB | Native JSON support for item_ids, failures | + +--- + +## Best Practices Applied + +### Laravel Conventions +- ✅ Queue jobs implement `ShouldQueue` interface +- ✅ Use Eloquent relationships, not raw queries +- ✅ Form validation via Filament rules +- ✅ PSR-12 code formatting (Laravel Pint) + +### Safety & Security +- ✅ Tenant isolation enforced at job level +- ✅ Type-to-confirm for ≥20 destructive items +- ✅ Fail-soft: continue on individual failures +- ✅ Circuit breaker: abort if >50% fail +- ✅ Audit logging for compliance + +### Performance +- ✅ Chunked processing (10-20 items) +- ✅ Indexed queries (tenant_id, ignored_at) +- ✅ Polling interval: 5s (not 1s spam) +- ✅ JSONB for flexible metadata storage + +### Testing +- ✅ Unit tests for jobs, scopes, eligibility +- ✅ Feature tests for E2E flows +- ✅ Pest assertions for progress tracking +- ✅ Manual QA checklist for UI flows + +--- + +## Rejected Alternatives + +| Alternative | Why Rejected | +|-------------|--------------| +| Bus::batch() | Adds complexity, not needed for sequential processing | +| Filament Pulse | Overkill for single-feature progress tracking | +| Pusher/WebSockets | Infrastructure overhead, 5s polling sufficient | +| Global tenant scopes | Less explicit, harder to debug, security risk | +| Custom modal component | More code, less reusable than Filament form | +| Hard delete without checks | Too risky, violates immutability principle | + +--- + +## Open Questions for Implementation + +1. **Chunk size**: Start with 10, benchmark if needed +2. **Polling interval**: 5s default, make configurable? +3. **Retention period**: 90 days for versions, make configurable? +4. **Max bulk items**: Hard limit at 500? 1000? +5. **Retry failed items**: Future enhancement or MVP? + +--- + +**Status**: Research Complete +**Next Step**: Generate data-model.md -- 2.45.2 From 9eb3a849e2db89a216f96df5c025775a3f9d0121 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 22 Dec 2025 01:32:05 +0100 Subject: [PATCH 06/21] feat(005): Generate task breakdown for Bulk Operations feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 98 tasks across 10 phases organized by user story: - Phase 1-2: Setup + Foundational (13 tasks) - Phase 3-4: US1 Bulk Delete + US2 Export (22 tasks) - MVP - Phase 5: US5 Type-to-Confirm (7 tasks) - Phase 6: US6 Progress Tracking (12 tasks) - Phase 7-9: US3 Prune + US4 Delete Runs + Backup Sets (31 tasks) - Phase 10: Polish + QA (13 tasks) MVP Scope: 35 tasks (16-22 hours) Full P1/P2: 85 tasks (26-34 hours) Production Ready: 98 tasks (30-40 hours) Each user story independently testable: - US1: Bulk delete policies (local, ignored_at flag) - US2: Bulk export to backup set - US3: Prune old policy versions (eligibility checks) - US4: Delete restore runs (skip running) - US5: Type-to-confirm for ≥20 items - US6: Progress tracking with Livewire polling Parallel opportunities identified: - All tests per story can run parallel - User stories can be worked on by different devs - Models/jobs in different files can be created parallel Critical path to MVP: T001→T007→T014→T024 (12-16 hours) --- specs/005-bulk-operations/tasks.md | 485 +++++++++++++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 specs/005-bulk-operations/tasks.md diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md new file mode 100644 index 0000000..1e5ff4d --- /dev/null +++ b/specs/005-bulk-operations/tasks.md @@ -0,0 +1,485 @@ +# Tasks: Feature 005 - Bulk Operations + +**Branch**: `feat/005-bulk-operations` | **Date**: 2025-12-22 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md), [data-model.md](./data-model.md), [research.md](./research.md) + +## Task Format + +- **Checkbox**: `- [ ]` for incomplete, `- [x]` for complete +- **Task ID**: Sequential T001, T002, T003... +- **[P] marker**: Task can run in parallel (different files, no blocking dependencies) +- **[Story] label**: User story tag (US1, US2, US3...) - omit for Setup/Foundational/Polish phases +- **File path**: Always include exact file path in description + +## Phase 1: Setup (Project Initialization) + +**Purpose**: Database schema and base infrastructure for bulk operations + +- [ ] T001 Create migration for `bulk_operation_runs` table in database/migrations/YYYY_MM_DD_HHMMSS_create_bulk_operation_runs_table.php +- [ ] T002 Create migration to add `ignored_at` column to policies table in database/migrations/YYYY_MM_DD_HHMMSS_add_ignored_at_to_policies_table.php +- [ ] T003 [P] Create BulkOperationRun model in app/Models/BulkOperationRun.php +- [ ] T004 [P] Create BulkOperationService in app/Services/BulkOperationService.php +- [ ] T005 Run migrations and verify schema: `./vendor/bin/sail artisan migrate` +- [ ] T006 Run Pint formatting: `./vendor/bin/sail composer pint` + +**Checkpoint**: Database ready, base models created + +--- + +## Phase 2: Foundational (Shared Components) + +**Purpose**: Core components used by ALL bulk operations - MUST complete before user stories + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [ ] T007 Extend Policy model with `ignored_at` scope and methods in app/Models/Policy.php +- [ ] T008 [P] Extend PolicyVersion model with `pruneEligible()` scope in app/Models/PolicyVersion.php +- [ ] T009 [P] Extend RestoreRun model with `deletable()` scope in app/Models/RestoreRun.php +- [ ] T010 Extend AuditLogger service to support bulk operation events in app/Services/Audit/AuditLogger.php (or equivalent) +- [ ] T011 Update SyncPoliciesJob to filter by `ignored_at IS NULL` in app/Jobs/SyncPoliciesJob.php +- [ ] T012 Create BulkOperationRun factory in database/factories/BulkOperationRunFactory.php +- [ ] T013 Create test seeder BulkOperationsTestSeeder in database/seeders/BulkOperationsTestSeeder.php + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - Bulk Delete Policies (Priority: P1) 🎯 MVP + +**Goal**: Enable admins to soft-delete multiple policies locally with `ignored_at` flag, preventing re-sync + +**Independent Test**: Select 15 policies → bulk delete → verify `ignored_at` set, policies hidden from listings, audit log created, Intune unchanged + +### Tests for User Story 1 + +- [ ] T014 [P] [US1] Write unit test for BulkPolicyDeleteJob in tests/Unit/BulkPolicyDeleteJobTest.php +- [ ] T015 [P] [US1] Write feature test for bulk delete <20 items (sync) in tests/Feature/BulkDeletePoliciesTest.php +- [ ] T016 [P] [US1] Write feature test for bulk delete ≥20 items (async) in tests/Feature/BulkDeletePoliciesAsyncTest.php +- [ ] T017 [P] [US1] Write permission test for bulk delete in tests/Unit/BulkActionPermissionTest.php + +### Implementation for User Story 1 + +- [ ] T018 [P] [US1] Create BulkPolicyDeleteJob in app/Jobs/BulkPolicyDeleteJob.php +- [ ] T019 [US1] Add bulk delete action to PolicyResource in app/Filament/Resources/PolicyResource.php +- [ ] T020 [US1] Implement type-to-confirm modal (≥20 items) in PolicyResource bulk action +- [ ] T021 [US1] Wire BulkOperationService to create tracking record and dispatch job +- [ ] T022 [US1] Test bulk delete with 10 policies (sync, manual QA) +- [ ] T023 [US1] Test bulk delete with 25 policies (async, manual QA) +- [ ] T024 [US1] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeletePoliciesTest.php` +- [ ] T025 [US1] Verify audit log entry created with correct metadata + +**Checkpoint**: Bulk delete policies working (sync + async), audit logged, tests passing + +--- + +## Phase 4: User Story 2 - Bulk Export Policies to Backup (Priority: P1) + +**Goal**: Enable admins to export multiple policies to a new Backup Set with progress tracking + +**Independent Test**: Select 25 policies → export to backup → verify BackupSet created, 25 BackupItems exist, progress notification shown + +### Tests for User Story 2 + +- [ ] T026 [P] [US2] Write unit test for BulkPolicyExportJob in tests/Unit/BulkPolicyExportJobTest.php +- [ ] T027 [P] [US2] Write feature test for bulk export in tests/Feature/BulkExportToBackupTest.php +- [ ] T028 [P] [US2] Write test for export with failures (3/25 fail) in tests/Feature/BulkExportFailuresTest.php + +### Implementation for User Story 2 + +- [ ] T029 [P] [US2] Create BulkPolicyExportJob in app/Jobs/BulkPolicyExportJob.php +- [ ] T030 [US2] Add bulk export action to PolicyResource in app/Filament/Resources/PolicyResource.php +- [ ] T031 [US2] Create export form with backup_name and include_assignments fields +- [ ] T032 [US2] Implement export logic (create BackupSet, capture BackupItems) +- [ ] T033 [US2] Handle partial failures (some policies fail to backup) +- [ ] T034 [US2] Test export with 30 policies (manual QA) +- [ ] T035 [US2] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkExportToBackupTest.php` + +**Checkpoint**: Bulk export working, BackupSets created, failures handled gracefully + +--- + +## Phase 5: User Story 5 - Type-to-Confirm (Priority: P1) + +**Goal**: Require typing "DELETE" for destructive operations with ≥20 items + +**Independent Test**: Bulk delete 25 policies → modal requires "DELETE" → button disabled until correct input → operation proceeds + +**Note**: This is implemented within US1 (T020) but tested separately here + +### Tests for User Story 5 + +- [ ] T036 [P] [US5] Write test for type-to-confirm with correct input in tests/Feature/BulkTypeToConfirmTest.php +- [ ] T037 [P] [US5] Write test for type-to-confirm with incorrect input (lowercase "delete") +- [ ] T038 [P] [US5] Write test for type-to-confirm disabled for <20 items + +### Validation for User Story 5 + +- [ ] T039 [US5] Manual test: Bulk delete 10 policies → confirm without typing (should work) +- [ ] T040 [US5] Manual test: Bulk delete 25 policies → type "delete" → button stays disabled +- [ ] T041 [US5] Manual test: Bulk delete 25 policies → type "DELETE" → button enables, operation proceeds +- [ ] T042 [US5] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkTypeToConfirmTest.php` + +**Checkpoint**: Type-to-confirm working correctly for all thresholds + +--- + +## Phase 6: User Story 6 - Progress Tracking (Priority: P2) + +**Goal**: Show real-time progress for queued bulk operations with Livewire polling + +**Independent Test**: Bulk delete 100 policies → progress notification updates every 5s → final notification shows outcomes + +### Tests for User Story 6 + +- [ ] T043 [P] [US6] Write test for progress updates in BulkOperationRun in tests/Unit/BulkOperationRunProgressTest.php +- [ ] T044 [P] [US6] Write feature test for progress notification in tests/Feature/BulkProgressNotificationTest.php +- [ ] T045 [P] [US6] Write test for circuit breaker (abort >50% fail) in tests/Unit/CircuitBreakerTest.php + +### Implementation for User Story 6 + +- [ ] T046 [P] [US6] Create Livewire progress component in app/Livewire/BulkOperationProgress.php +- [ ] T047 [P] [US6] Create progress view in resources/views/livewire/bulk-operation-progress.blade.php +- [ ] T048 [US6] Update BulkPolicyDeleteJob to emit progress after each chunk +- [ ] T049 [US6] Update BulkPolicyExportJob to emit progress after each chunk +- [ ] T050 [US6] Implement circuit breaker logic (abort if >50% fail) in jobs +- [ ] T051 [US6] Add progress polling to Filament notifications or sidebar widget +- [ ] T052 [US6] Test progress with 100 policies (manual QA, observe updates) +- [ ] T053 [US6] Test circuit breaker with mock failures (manual QA) +- [ ] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php` + +**Checkpoint**: Progress tracking working, polling updates UI, circuit breaker aborts high-failure jobs + +--- + +## Phase 7: User Story 3 - Bulk Delete Policy Versions (Priority: P2) + +**Goal**: Enable admins to prune old policy versions that are NOT referenced and meet retention threshold (>90 days) + +**Independent Test**: Select 30 old versions → bulk prune → verify eligible deleted, ineligible skipped with reasons + +### Tests for User Story 3 + +- [ ] T055 [P] [US3] Write unit test for pruneEligible() scope in tests/Unit/PolicyVersionEligibilityTest.php +- [ ] T056 [P] [US3] Write unit test for BulkPolicyVersionPruneJob in tests/Unit/BulkPolicyVersionPruneJobTest.php +- [ ] T057 [P] [US3] Write feature test for bulk prune in tests/Feature/BulkPruneVersionsTest.php +- [ ] T058 [P] [US3] Write test for skip reasons (referenced, too recent, current) in tests/Feature/BulkPruneSkipReasonsTest.php + +### Implementation for User Story 3 + +- [ ] T059 [P] [US3] Create BulkPolicyVersionPruneJob in app/Jobs/BulkPolicyVersionPruneJob.php +- [ ] T060 [US3] Add bulk delete action to PolicyVersionResource in app/Filament/Resources/PolicyVersionResource.php +- [ ] T061 [US3] Implement eligibility check per version in job (reuse pruneEligible scope) +- [ ] T062 [US3] Collect skip reasons for ineligible versions +- [ ] T063 [US3] Add type-to-confirm for ≥20 versions +- [ ] T064 [US3] Test prune with 30 versions (15 eligible, 15 ineligible) - manual QA +- [ ] T065 [US3] Verify skip reasons in notification and audit log +- [ ] T066 [US3] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkPruneVersionsTest.php` + +**Checkpoint**: Policy versions pruning working, eligibility enforced, skip reasons logged + +--- + +## Phase 8: User Story 4 - Bulk Delete Restore Runs (Priority: P2) + +**Goal**: Enable admins to delete completed/failed restore runs to declutter history + +**Independent Test**: Select 20 completed runs → bulk delete → verify soft-deleted, running runs skipped + +### Tests for User Story 4 + +- [ ] T067 [P] [US4] Write unit test for deletable() scope in tests/Unit/RestoreRunDeletableTest.php +- [ ] T068 [P] [US4] Write unit test for BulkRestoreRunDeleteJob in tests/Unit/BulkRestoreRunDeleteJobTest.php +- [ ] T069 [P] [US4] Write feature test for bulk delete restore runs in tests/Feature/BulkDeleteRestoreRunsTest.php +- [ ] T070 [P] [US4] Write test for mixed statuses (skip running) in tests/Feature/BulkDeleteMixedStatusTest.php + +### Implementation for User Story 4 + +- [ ] T071 [P] [US4] Create BulkRestoreRunDeleteJob in app/Jobs/BulkRestoreRunDeleteJob.php +- [ ] T072 [US4] Add bulk delete action to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php +- [ ] T073 [US4] Filter by deletable() scope (completed, failed, aborted only) +- [ ] T074 [US4] Skip running restore runs with warning +- [ ] T075 [US4] Add type-to-confirm for ≥20 runs +- [ ] T076 [US4] Test delete with 20 completed + 5 running (manual QA, verify 5 skipped) +- [ ] T077 [US4] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteRestoreRunsTest.php` + +**Checkpoint**: Restore runs bulk delete working, running runs protected, skip warnings shown + +--- + +## Phase 9: Additional Resource - Bulk Delete Backup Sets (Priority: P2) + +**Goal**: Enable admins to delete backup sets with cascade-delete of backup items + +**Independent Test**: Select 10 backup sets → bulk delete → verify sets deleted, items cascade-deleted + +### Tests for Additional Resource + +- [ ] T078 [P] Write unit test for BulkBackupSetDeleteJob in tests/Unit/BulkBackupSetDeleteJobTest.php +- [ ] T079 [P] Write feature test for bulk delete backup sets in tests/Feature/BulkDeleteBackupSetsTest.php + +### Implementation for Additional Resource + +- [ ] T080 [P] Create BulkBackupSetDeleteJob in app/Jobs/BulkBackupSetDeleteJob.php +- [ ] T081 Add bulk delete action to BackupSetResource in app/Filament/Resources/BackupSetResource.php +- [ ] T082 Verify cascade-delete logic for backup items (should be automatic via foreign key) +- [ ] T083 Add type-to-confirm for ≥10 sets +- [ ] T084 Test delete with 15 backup sets (manual QA) +- [ ] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php` + +**Checkpoint**: Backup sets bulk delete working, cascade-delete verified + +--- + +## Phase 10: Polish & Cross-Cutting Concerns + +**Purpose**: Documentation, cleanup, performance optimization + +- [ ] T086 [P] Update README.md with bulk operations feature description +- [ ] T087 [P] Update quickstart.md with manual testing scenarios (already done in planning) +- [ ] T088 Code cleanup: Remove debug statements, refactor duplicated logic +- [ ] T089 Performance test: Bulk delete 500 policies (should complete <5 minutes) +- [ ] T090 Load test: Concurrent bulk operations (2-3 admins, different resources) +- [ ] T091 [P] Security review: Verify tenant isolation in all jobs +- [ ] T092 [P] Permission audit: Verify all bulk actions respect RBAC +- [ ] T093 Run full test suite: `./vendor/bin/sail artisan test` +- [ ] T094 Run Pint formatting: `./vendor/bin/sail composer pint` +- [ ] T095 Manual QA checklist: Complete all scenarios from quickstart.md +- [ ] T096 Document configuration options (chunk size, polling interval, retention days) +- [ ] T097 Create BulkOperationRun resource page in Filament (view progress, retry failed) +- [ ] T098 Add bulk operation metrics to dashboard (total runs, success rate) + +**Checkpoint**: Feature polished, tested, documented, ready for staging deployment + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +``` +Setup (Phase 1) + ↓ +Foundational (Phase 2) ← BLOCKS all user stories + ↓ +┌───────────────────────────────────┐ +│ User Stories (Parallel Capable) │ +├───────────────────────────────────┤ +│ US1: Bulk Delete Policies (P1) │ ← MVP +│ US2: Bulk Export (P1) │ ← MVP +│ US5: Type-to-Confirm (P1) │ ← Embedded in US1 +├───────────────────────────────────┤ +│ US6: Progress Tracking (P2) │ ← Enhances US1, US2 +│ US3: Prune Versions (P2) │ +│ US4: Delete Runs (P2) │ +│ Additional: Delete Sets (P2) │ +└───────────────────────────────────┘ + ↓ +Polish (Phase 10) +``` + +### User Story Dependencies + +- **US1 (P1)**: Depends on Foundational (T007, T010, T011). Fully independent after that. +- **US2 (P1)**: Depends on Foundational (T010). Fully independent after that. +- **US5 (P1)**: Implemented within US1 (T020), tested separately. +- **US6 (P2)**: Depends on US1 and US2 (adds progress to existing jobs). Can be implemented after US1/US2 are functional. +- **US3 (P2)**: Depends on Foundational (T008, T010). Fully independent. +- **US4 (P2)**: Depends on Foundational (T009, T010). Fully independent. +- **Additional (P2)**: Depends on Foundational (T010). Fully independent. + +### Parallel Opportunities Within Phases + +**Setup (Phase 1)**: +- T003, T004 can run in parallel (different files) + +**Foundational (Phase 2)**: +- T008, T009, T012 can run in parallel (different files) +- T007, T010, T011 must be sequential (modify same services) + +**User Story 1 Tests (T014-T017)**: All parallel (different test files) + +**User Story 1 Implementation**: +- T018 parallel with T019-T021 (different files initially) +- T019-T021 sequential (same file edits) +- T022-T025 sequential (testing/validation) + +**User Story 2 Tests (T026-T028)**: All parallel + +**User Story 2 Implementation**: +- T029 parallel with T030-T031 (different files) +- T030-T035 sequential (same file, progressive features) + +**User Story 3-4 and Additional**: Can all run in parallel after Foundational complete (different resources, no overlap) + +### Critical Path (Fastest Route to MVP) + +``` +T001 → T002 → T005 (Setup migrations, run) + ↓ +T007 → T010 → T011 (Foundational: Policy scope, AuditLogger, SyncJob) + ↓ +T014-T017 (US1 tests) → T018 → T019 → T020 (US1 core) → T024 (verify) + ↓ +MVP Ready: Bulk delete policies with type-to-confirm +``` + +**Estimated MVP Time**: ~12-16 hours (Setup + Foundational + US1) + +--- + +## Parallel Example: User Story 1 Implementation + +If working with a team, these tasks can run concurrently: + +**Developer A**: +```bash +# Write tests first (TDD) +tests/Unit/BulkPolicyDeleteJobTest.php (T014) +tests/Feature/BulkDeletePoliciesTest.php (T015) +``` + +**Developer B** (after foundational complete): +```bash +# Implement job +app/Jobs/BulkPolicyDeleteJob.php (T018) +``` + +**Developer C** (after T018 complete): +```bash +# Wire up Filament UI +app/Filament/Resources/PolicyResource.php (T019-T021) +``` + +**Developer A** (after implementation complete): +```bash +# Run tests, verify passing +./vendor/bin/sail artisan test tests/Feature/BulkDeletePoliciesTest.php (T024) +``` + +--- + +## Parallel Example: Multiple User Stories + +After Foundational phase completes, these can proceed in parallel (different team members): + +**Team Member 1**: US1 Bulk Delete Policies (T014-T025) +**Team Member 2**: US2 Bulk Export (T026-T035) +**Team Member 3**: US3 Prune Versions (T055-T066) +**Team Member 4**: US6 Progress Tracking (T043-T054) + +All integrate at the end without conflicts (different resources/files). + +--- + +## Testing Strategy + +### Unit Tests (tests/Unit/) +- BulkPolicyDeleteJob +- BulkPolicyExportJob +- BulkPolicyVersionPruneJob +- BulkBackupSetDeleteJob +- BulkRestoreRunDeleteJob +- BulkActionPermission +- PolicyVersion::pruneEligible scope +- RestoreRun::deletable scope +- BulkOperationRun progress helpers +- Circuit breaker logic + +### Feature Tests (tests/Feature/) +- BulkDeletePolicies (sync, async) +- BulkExportToBackup +- BulkTypeToConfirm +- BulkProgressNotification +- BulkPruneVersions (eligibility, skip reasons) +- BulkDeleteRestoreRuns (mixed statuses) +- BulkDeleteBackupSets (cascade) + +### Manual QA Scenarios (from quickstart.md) +1. Bulk delete 10 policies (sync, no type-to-confirm) +2. Bulk delete 25 policies (async, type-to-confirm) +3. Bulk export 30 policies (progress tracking) +4. Bulk prune 30 versions (eligibility checks) +5. Circuit breaker with mock failures (>50% fail) + +--- + +## Implementation Strategy + +### MVP First (P1 Features Only) + +**Goal**: Ship minimal viable feature to get user feedback + +**Scope**: +- Setup (T001-T006) +- Foundational (T007-T013) +- US1: Bulk Delete Policies (T014-T025) +- US2: Bulk Export (T026-T035) +- US5: Type-to-Confirm (T036-T042) - already in US1 + +**Estimated Time**: 16-22 hours +**Deliverable**: Admins can bulk delete and export policies with safety gates + +### Iteration 2 (P2 Features) + +**Goal**: Add progress tracking and additional resources + +**Scope**: +- US6: Progress Tracking (T043-T054) +- US3: Prune Versions (T055-T066) +- US4: Delete Runs (T067-T077) +- Additional: Delete Sets (T078-T085) + +**Estimated Time**: 10-12 hours +**Deliverable**: Full feature set with progress UI and all resources + +### Iteration 3 (Polish) + +**Goal**: Production readiness + +**Scope**: +- Polish (T086-T098) +- Performance testing +- Security audit +- Documentation finalization + +**Estimated Time**: 4-6 hours +**Deliverable**: Production-ready feature + +--- + +## Success Criteria + +- ✅ All P1 tests passing (US1, US2, US5) +- ✅ Bulk delete 100 policies in <2 minutes +- ✅ Type-to-confirm prevents accidents (≥20 items) +- ✅ Progress updates every 5-10s for queued jobs +- ✅ Audit log captures per-item outcomes +- ✅ Circuit breaker aborts at >50% failure rate +- ✅ Tenant isolation enforced (verified via tests) +- ✅ Manual QA checklist complete (5 scenarios) + +--- + +## Total Task Count + +- **Setup**: 6 tasks (T001-T006) +- **Foundational**: 7 tasks (T007-T013) +- **US1**: 12 tasks (T014-T025) +- **US2**: 10 tasks (T026-T035) +- **US5**: 7 tasks (T036-T042) +- **US6**: 12 tasks (T043-T054) +- **US3**: 12 tasks (T055-T066) +- **US4**: 11 tasks (T067-T077) +- **Additional**: 8 tasks (T078-T085) +- **Polish**: 13 tasks (T086-T098) + +**Total**: 98 tasks + +**MVP Scope**: 35 tasks (Setup + Foundational + US1 + US2 + US5) +**Full P1/P2 Scope**: 85 tasks (all user stories) +**Production Ready**: 98 tasks (including polish) + +--- + +**Status**: Tasks Ready for Implementation +**Next Step**: Begin Phase 1 (Setup) → Run migrations → Start US1 tests -- 2.45.2 From e62b0208bb8ba4987719ed878076c0f31db5e329 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 01:20:04 +0100 Subject: [PATCH 07/21] feat: bulk operations (policies, policy versions) --- app/Filament/Resources/PolicyResource.php | 172 +++++++++++++- .../PolicyResource/Pages/ListPolicies.php | 2 + .../Resources/PolicyVersionResource.php | 216 ++++++++++++++++- app/Jobs/BulkPolicyDeleteJob.php | 185 +++++++++++++++ app/Jobs/BulkPolicyExportJob.php | 219 ++++++++++++++++++ app/Jobs/BulkPolicyUnignoreJob.php | 116 ++++++++++ app/Jobs/BulkPolicyVersionForceDeleteJob.php | 148 ++++++++++++ app/Jobs/BulkPolicyVersionPruneJob.php | 178 ++++++++++++++ app/Jobs/BulkPolicyVersionRestoreJob.php | 148 ++++++++++++ app/Livewire/BulkOperationProgress.php | 47 ++++ app/Models/BulkOperationRun.php | 53 +++++ app/Models/Policy.php | 21 ++ app/Models/PolicyVersion.php | 10 + app/Models/RestoreRun.php | 5 + app/Providers/Filament/AdminPanelProvider.php | 6 + app/Services/BulkOperationService.php | 189 +++++++++++++++ app/Services/Intune/PolicySyncService.php | 1 + .../factories/BulkOperationRunFactory.php | 34 +++ database/factories/PolicyFactory.php | 28 +++ database/factories/PolicyVersionFactory.php | 33 +++ database/factories/TenantFactory.php | 27 +++ ...15901_create_bulk_operation_runs_table.php | 50 ++++ ...15905_add_ignored_at_to_policies_table.php | 28 +++ ...2_24_002001_create_notifications_table.php | 31 +++ ...ease_bulk_operation_runs_status_length.php | 28 +++ database/seeders/BulkOperationsTestSeeder.php | 33 +++ docker-compose.yml | 24 ++ .../bulk-operation-progress-wrapper.blade.php | 1 + .../bulk-operation-progress.blade.php | 75 ++++++ specs/005-bulk-operations/tasks.md | 124 +++++----- tests/Feature/BulkDeletePoliciesAsyncTest.php | 30 +++ tests/Feature/BulkDeletePoliciesTest.php | 34 +++ tests/Feature/BulkExportFailuresTest.php | 51 ++++ tests/Feature/BulkExportToBackupTest.php | 41 ++++ .../BulkForceDeletePolicyVersionsTest.php | 49 ++++ .../Feature/BulkProgressNotificationTest.php | 51 ++++ tests/Feature/BulkPruneSkipReasonsTest.php | 61 +++++ tests/Feature/BulkPruneVersionsTest.php | 44 ++++ .../Feature/BulkRestorePolicyVersionsTest.php | 48 ++++ tests/Feature/BulkTypeToConfirmTest.php | 53 +++++ tests/Feature/BulkUnignorePoliciesTest.php | 41 ++++ .../Jobs/PolicySyncIgnoredRevivalTest.php | 159 +++++++++++++ tests/Unit/BulkActionPermissionTest.php | 14 ++ tests/Unit/BulkOperationAbortMethodTest.php | 24 ++ tests/Unit/BulkOperationRunProgressTest.php | 33 +++ tests/Unit/BulkPolicyDeleteJobTest.php | 61 +++++ tests/Unit/BulkPolicyExportJobTest.php | 88 +++++++ .../BulkPolicyVersionForceDeleteJobTest.php | 69 ++++++ tests/Unit/BulkPolicyVersionPruneJobTest.php | 120 ++++++++++ .../Unit/BulkPolicyVersionRestoreJobTest.php | 88 +++++++ tests/Unit/CircuitBreakerTest.php | 47 ++++ tests/Unit/PolicyVersionEligibilityTest.php | 84 +++++++ 52 files changed, 3460 insertions(+), 62 deletions(-) create mode 100644 app/Jobs/BulkPolicyDeleteJob.php create mode 100644 app/Jobs/BulkPolicyExportJob.php create mode 100644 app/Jobs/BulkPolicyUnignoreJob.php create mode 100644 app/Jobs/BulkPolicyVersionForceDeleteJob.php create mode 100644 app/Jobs/BulkPolicyVersionPruneJob.php create mode 100644 app/Jobs/BulkPolicyVersionRestoreJob.php create mode 100644 app/Livewire/BulkOperationProgress.php create mode 100644 app/Models/BulkOperationRun.php create mode 100644 app/Services/BulkOperationService.php create mode 100644 database/factories/BulkOperationRunFactory.php create mode 100644 database/factories/PolicyFactory.php create mode 100644 database/factories/PolicyVersionFactory.php create mode 100644 database/factories/TenantFactory.php create mode 100644 database/migrations/2025_12_23_215901_create_bulk_operation_runs_table.php create mode 100644 database/migrations/2025_12_23_215905_add_ignored_at_to_policies_table.php create mode 100644 database/migrations/2025_12_24_002001_create_notifications_table.php create mode 100644 database/migrations/2025_12_24_005055_increase_bulk_operation_runs_status_length.php create mode 100644 database/seeders/BulkOperationsTestSeeder.php create mode 100644 resources/views/livewire/bulk-operation-progress-wrapper.blade.php create mode 100644 resources/views/livewire/bulk-operation-progress.blade.php create mode 100644 tests/Feature/BulkDeletePoliciesAsyncTest.php create mode 100644 tests/Feature/BulkDeletePoliciesTest.php create mode 100644 tests/Feature/BulkExportFailuresTest.php create mode 100644 tests/Feature/BulkExportToBackupTest.php create mode 100644 tests/Feature/BulkForceDeletePolicyVersionsTest.php create mode 100644 tests/Feature/BulkProgressNotificationTest.php create mode 100644 tests/Feature/BulkPruneSkipReasonsTest.php create mode 100644 tests/Feature/BulkPruneVersionsTest.php create mode 100644 tests/Feature/BulkRestorePolicyVersionsTest.php create mode 100644 tests/Feature/BulkTypeToConfirmTest.php create mode 100644 tests/Feature/BulkUnignorePoliciesTest.php create mode 100644 tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php create mode 100644 tests/Unit/BulkActionPermissionTest.php create mode 100644 tests/Unit/BulkOperationAbortMethodTest.php create mode 100644 tests/Unit/BulkOperationRunProgressTest.php create mode 100644 tests/Unit/BulkPolicyDeleteJobTest.php create mode 100644 tests/Unit/BulkPolicyExportJobTest.php create mode 100644 tests/Unit/BulkPolicyVersionForceDeleteJobTest.php create mode 100644 tests/Unit/BulkPolicyVersionPruneJobTest.php create mode 100644 tests/Unit/BulkPolicyVersionRestoreJobTest.php create mode 100644 tests/Unit/CircuitBreakerTest.php create mode 100644 tests/Unit/PolicyVersionEligibilityTest.php diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index c132df3..cf762de 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -4,21 +4,31 @@ use App\Filament\Resources\PolicyResource\Pages; 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\Tenant; +use App\Services\BulkOperationService; use App\Services\Intune\PolicyNormalizer; use BackedEnum; use Filament\Actions; +use Filament\Actions\BulkAction; +use Filament\Actions\BulkActionGroup; +use Filament\Forms; use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\ViewEntry; +use Filament\Notifications\Notification; use Filament\Resources\Resource; use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Schema; use Filament\Tables; +use Filament\Tables\Contracts\HasTable; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use UnitEnum; class PolicyResource extends Resource @@ -253,6 +263,30 @@ public static function table(Table $table): Table ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ + Tables\Filters\SelectFilter::make('visibility') + ->label('Visibility') + ->options([ + 'active' => 'Active', + 'ignored' => 'Ignored', + ]) + ->default('active') + ->query(function (Builder $query, array $data) { + $value = $data['value'] ?? null; + + if (blank($value)) { + return; + } + + if ($value === 'active') { + $query->whereNull('ignored_at'); + + return; + } + + if ($value === 'ignored') { + $query->whereNotNull('ignored_at'); + } + }), Tables\Filters\SelectFilter::make('policy_type') ->options(function () { return collect(config('tenantpilot.supported_policy_types', [])) @@ -283,12 +317,146 @@ public static function table(Table $table): Table $query->whereIn('policy_type', $types); }), Tables\Filters\SelectFilter::make('platform') - ->options(fn () => Policy::query()->distinct()->pluck('platform', 'platform')->filter()->all()), + ->options(fn () => Policy::query() + ->distinct() + ->pluck('platform', 'platform') + ->filter() + ->reject(fn ($platform) => is_string($platform) && strtolower($platform) === 'all') + ->all()), ]) ->actions([ Actions\ViewAction::make(), ]) - ->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 diff --git a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php index 601b9c0..e3743d2 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php +++ b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php @@ -34,12 +34,14 @@ protected function getHeaderActions(): array ->title('Policy sync completed') ->body(count($synced).' policies synced') ->success() + ->sendToDatabase(auth()->user()) ->send(); } catch (\Throwable $e) { Notification::make() ->title('Policy sync failed') ->body($e->getMessage()) ->danger() + ->sendToDatabase(auth()->user()) ->send(); } }), diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index fb2a570..89cce02 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -3,12 +3,20 @@ namespace App\Filament\Resources; use App\Filament\Resources\PolicyVersionResource\Pages; +use App\Jobs\BulkPolicyVersionForceDeleteJob; +use App\Jobs\BulkPolicyVersionPruneJob; +use App\Jobs\BulkPolicyVersionRestoreJob; use App\Models\PolicyVersion; +use App\Models\Tenant; +use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\VersionDiff; use BackedEnum; use Filament\Actions; +use Filament\Actions\BulkAction; +use Filament\Actions\BulkActionGroup; +use Filament\Forms; use Filament\Infolists; use Filament\Notifications\Notification; use Filament\Resources\Resource; @@ -16,7 +24,11 @@ use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Schema; use Filament\Tables; +use Filament\Tables\Contracts\HasTable; +use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; use UnitEnum; class PolicyVersionResource extends Resource @@ -112,7 +124,11 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(), ]) ->filters([ - Tables\Filters\TrashedFilter::make(), + TrashedFilter::make() + ->label('Archived') + ->placeholder('Active') + ->trueLabel('All') + ->falseLabel('Archived'), ]) ->actions([ Actions\ViewAction::make() @@ -169,9 +185,205 @@ public static function table(Table $table): Table ->success() ->send(); }), + + Actions\Action::make('restore') + ->label('Restore') + ->color('success') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->visible(fn (PolicyVersion $record) => $record->trashed()) + ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { + $record->restore(); + + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'policy_version.restored', + resourceType: 'policy_version', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] + ); + } + + Notification::make() + ->title('Policy version restored') + ->success() + ->send(); + }), ])->icon('heroicon-o-ellipsis-vertical'), ]) - ->bulkActions([]); + ->bulkActions([ + BulkActionGroup::make([ + BulkAction::make('bulk_prune_versions') + ->label('Prune Versions') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.') + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return $isOnlyTrashed; + }) + ->form(function (Collection $records) { + $fields = [ + Forms\Components\TextInput::make('retention_days') + ->label('Retention Days') + ->helperText('Versions captured within the last N days will be skipped.') + ->numeric() + ->required() + ->default(90) + ->minValue(1), + ]; + + if ($records->count() >= 20) { + $fields[] = Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]); + } + + return $fields; + }) + ->action(function (Collection $records, array $data) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $retentionDays = (int) ($data['retention_days'] ?? 90); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy_version', 'prune', $ids, $count); + + if ($count >= 20) { + Notification::make() + ->title('Bulk prune started') + ->body("Pruning {$count} policy versions in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkPolicyVersionPruneJob::dispatch($run->id, $retentionDays); + } else { + BulkPolicyVersionPruneJob::dispatchSync($run->id, $retentionDays); + } + }) + ->deselectRecordsAfterCompletion(), + + BulkAction::make('bulk_restore_versions') + ->label('Restore Versions') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?") + ->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.') + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy_version', 'restore', $ids, $count); + + if ($count >= 20) { + Notification::make() + ->title('Bulk restore started') + ->body("Restoring {$count} policy versions in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkPolicyVersionRestoreJob::dispatch($run->id); + } else { + BulkPolicyVersionRestoreJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + + BulkAction::make('bulk_force_delete_versions') + ->label('Force Delete Versions') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?") + ->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.') + ->form([ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]) + ->action(function (Collection $records, array $data) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', $ids, $count); + + if ($count >= 20) { + Notification::make() + ->title('Bulk force delete started') + ->body("Force deleting {$count} policy versions in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkPolicyVersionForceDeleteJob::dispatch($run->id); + } else { + BulkPolicyVersionForceDeleteJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + ]), + ]); + } + + public static function getEloquentQuery(): Builder + { + $tenantId = Tenant::current()->getKey(); + + return parent::getEloquentQuery() + ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) + ->with('policy'); } public static function getPages(): array diff --git a/app/Jobs/BulkPolicyDeleteJob.php b/app/Jobs/BulkPolicyDeleteJob.php new file mode 100644 index 0000000..4cf6827 --- /dev/null +++ b/app/Jobs/BulkPolicyDeleteJob.php @@ -0,0 +1,185 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + + return; + + } + + $service->start($run); + + try { + + $itemCount = 0; + $succeeded = 0; + $failed = 0; + $skipped = 0; + $failures = []; + + $chunkSize = 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; + } + + } +} diff --git a/app/Jobs/BulkPolicyExportJob.php b/app/Jobs/BulkPolicyExportJob.php new file mode 100644 index 0000000..eabfe37 --- /dev/null +++ b/app/Jobs/BulkPolicyExportJob.php @@ -0,0 +1,219 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + try { + // Create Backup Set + $backupSet = BackupSet::create([ + 'tenant_id' => $run->tenant_id, + 'name' => $this->backupName, + // 'description' => $this->backupDescription, // Not in schema + 'status' => 'completed', + 'created_by' => $run->user?->name ?? (string) $run->user_id, // Schema has created_by string + 'item_count' => count($run->item_ids), + 'completed_at' => now(), + ]); + + $itemCount = 0; + $succeeded = 0; + $failed = 0; + $failures = []; + $chunkSize = 10; + + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach ($run->item_ids as $policyId) { + $itemCount++; + + try { + $policy = Policy::find($policyId); + + if (! $policy) { + $service->recordFailure($run, (string) $policyId, 'Policy not found'); + $failed++; + $failures[] = [ + 'item_id' => (string) $policyId, + 'reason' => 'Policy not found', + 'timestamp' => now()->toIso8601String(), + ]; + + if ($failed > $failureThreshold) { + $backupSet->update(['status' => 'failed']); + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Export Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + // Get latest version for snapshot + $latestVersion = $policy->versions()->orderByDesc('captured_at')->first(); + + if (! $latestVersion) { + $service->recordFailure($run, (string) $policyId, 'No versions available for policy'); + $failed++; + $failures[] = [ + 'item_id' => (string) $policyId, + 'reason' => 'No versions available for policy', + 'timestamp' => now()->toIso8601String(), + ]; + + if ($failed > $failureThreshold) { + $backupSet->update(['status' => 'failed']); + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Export Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + // Create Backup Item + BackupItem::create([ + 'tenant_id' => $run->tenant_id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, // Added + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform ?? null, // Added + // 'display_name' => $policy->display_name, // Not in schema, maybe in metadata? + 'payload' => $latestVersion->snapshot, // Mapped to payload + 'metadata' => [ + 'display_name' => $policy->display_name, // Stored in metadata + 'version_captured_at' => $latestVersion->captured_at->toIso8601String(), + ], + ]); + + $service->recordSuccess($run); + $succeeded++; + + } catch (Throwable $e) { + $service->recordFailure($run, (string) $policyId, $e->getMessage()); + $failed++; + $failures[] = [ + 'item_id' => (string) $policyId, + 'reason' => $e->getMessage(), + 'timestamp' => now()->toIso8601String(), + ]; + + if ($failed > $failureThreshold) { + $backupSet->update(['status' => 'failed']); + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Export Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + // Refresh the run from database every 10 items to avoid stale data + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + // Update BackupSet item count (if denormalized) or just leave it + // Assuming BackupSet might need an item count or status update + + $service->complete($run); + + if ($succeeded > 0 || $failed > 0) { + $message = "Successfully exported {$succeeded} policies to backup '{$this->backupName}'"; + if ($failed > 0) { + $message .= " ({$failed} failed)"; + } + $message .= '.'; + + Notification::make() + ->title('Bulk Export Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } + + } catch (Throwable $e) { + $service->fail($run, $e->getMessage()); + + // Reload run with user relationship + $run->refresh(); + $run->load('user'); + + if ($run->user) { + Notification::make() + ->title('Bulk Export Failed') + ->body($e->getMessage()) + ->icon('heroicon-o-x-circle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + throw $e; + } + } +} diff --git a/app/Jobs/BulkPolicyUnignoreJob.php b/app/Jobs/BulkPolicyUnignoreJob.php new file mode 100644 index 0000000..b07a5cc --- /dev/null +++ b/app/Jobs/BulkPolicyUnignoreJob.php @@ -0,0 +1,116 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + try { + $itemCount = 0; + $succeeded = 0; + $failed = 0; + $skipped = 0; + + $chunkSize = 10; + + foreach ($run->item_ids as $policyId) { + $itemCount++; + + try { + $policy = Policy::find($policyId); + + if (! $policy) { + $service->recordFailure($run, (string) $policyId, 'Policy not found'); + $failed++; + + continue; + } + + if (! $policy->ignored_at) { + $service->recordSkipped($run); + $skipped++; + + continue; + } + + $policy->unignore(); + + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $policyId, $e->getMessage()); + $failed++; + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if ($run->user) { + $message = "Restored {$succeeded} policies"; + + if ($skipped > 0) { + $message .= " ({$skipped} skipped)"; + } + + if ($failed > 0) { + $message .= " ({$failed} failed)"; + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Restore Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } + } catch (Throwable $e) { + $service->fail($run, $e->getMessage()); + + $run->refresh(); + $run->load('user'); + + if ($run->user) { + Notification::make() + ->title('Bulk Restore Failed') + ->body($e->getMessage()) + ->icon('heroicon-o-x-circle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + throw $e; + } + } +} diff --git a/app/Jobs/BulkPolicyVersionForceDeleteJob.php b/app/Jobs/BulkPolicyVersionForceDeleteJob.php new file mode 100644 index 0000000..6c40173 --- /dev/null +++ b/app/Jobs/BulkPolicyVersionForceDeleteJob.php @@ -0,0 +1,148 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + $itemCount = 0; + $succeeded = 0; + $failed = 0; + $skipped = 0; + $skipReasons = []; + + $chunkSize = 10; + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $versionId) { + $itemCount++; + + try { + /** @var PolicyVersion|null $version */ + $version = PolicyVersion::withTrashed() + ->where('tenant_id', $run->tenant_id) + ->whereKey($versionId) + ->first(); + + if (! $version) { + $service->recordFailure($run, (string) $versionId, 'Policy version not found'); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Force Delete Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if (! $version->trashed()) { + $service->recordSkippedWithReason($run, (string) $version->id, 'Not archived'); + $skipped++; + $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; + + continue; + } + + $version->forceDelete(); + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $versionId, $e->getMessage()); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Force Delete Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if ($run->user) { + $message = "Force deleted {$succeeded} policy versions"; + if ($skipped > 0) { + $message .= " ({$skipped} skipped)"; + } + if ($failed > 0) { + $message .= " ({$failed} failed)"; + } + + if (! empty($skipReasons)) { + $summary = collect($skipReasons) + ->sortDesc() + ->map(fn (int $count, string $reason) => "{$reason} ({$count})") + ->take(3) + ->implode(', '); + + if ($summary !== '') { + $message .= " Skip reasons: {$summary}."; + } + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Force Delete Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } + } +} diff --git a/app/Jobs/BulkPolicyVersionPruneJob.php b/app/Jobs/BulkPolicyVersionPruneJob.php new file mode 100644 index 0000000..2bcce1d --- /dev/null +++ b/app/Jobs/BulkPolicyVersionPruneJob.php @@ -0,0 +1,178 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + $itemCount = 0; + $succeeded = 0; + $failed = 0; + $skipped = 0; + $skipReasons = []; + + $chunkSize = 10; + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $versionId) { + $itemCount++; + + try { + /** @var PolicyVersion|null $version */ + $version = PolicyVersion::withTrashed() + ->where('tenant_id', $run->tenant_id) + ->whereKey($versionId) + ->first(); + + if (! $version) { + $service->recordFailure($run, (string) $versionId, 'Policy version not found'); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Prune Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if ($version->trashed()) { + $service->recordSkippedWithReason($run, (string) $version->id, 'Already archived'); + $skipped++; + $skipReasons['Already archived'] = ($skipReasons['Already archived'] ?? 0) + 1; + + continue; + } + + $eligible = PolicyVersion::query() + ->where('tenant_id', $run->tenant_id) + ->whereKey($version->id) + ->pruneEligible($this->retentionDays) + ->exists(); + + if (! $eligible) { + $capturedAt = $version->captured_at; + $isTooRecent = $capturedAt && $capturedAt->gte(now()->subDays($this->retentionDays)); + + $latestVersionNumber = PolicyVersion::query() + ->where('tenant_id', $run->tenant_id) + ->where('policy_id', $version->policy_id) + ->whereNull('deleted_at') + ->max('version_number'); + + $isCurrent = $latestVersionNumber !== null && (int) $version->version_number === (int) $latestVersionNumber; + + $reason = $isCurrent + ? 'Current version' + : ($isTooRecent ? 'Too recent' : 'Not eligible'); + + $service->recordSkippedWithReason($run, (string) $version->id, $reason); + $skipped++; + $skipReasons[$reason] = ($skipReasons[$reason] ?? 0) + 1; + + continue; + } + + $version->delete(); + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $versionId, $e->getMessage()); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Prune Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if ($run->user) { + $message = "Pruned {$succeeded} policy versions"; + if ($skipped > 0) { + $message .= " ({$skipped} skipped)"; + } + if ($failed > 0) { + $message .= " ({$failed} failed)"; + } + + if (! empty($skipReasons)) { + $summary = collect($skipReasons) + ->sortDesc() + ->map(fn (int $count, string $reason) => "{$reason} ({$count})") + ->take(3) + ->implode(', '); + + if ($summary !== '') { + $message .= " Skip reasons: {$summary}."; + } + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Prune Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } + } +} diff --git a/app/Jobs/BulkPolicyVersionRestoreJob.php b/app/Jobs/BulkPolicyVersionRestoreJob.php new file mode 100644 index 0000000..7170820 --- /dev/null +++ b/app/Jobs/BulkPolicyVersionRestoreJob.php @@ -0,0 +1,148 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + $itemCount = 0; + $succeeded = 0; + $failed = 0; + $skipped = 0; + $skipReasons = []; + + $chunkSize = 10; + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $versionId) { + $itemCount++; + + try { + /** @var PolicyVersion|null $version */ + $version = PolicyVersion::withTrashed() + ->where('tenant_id', $run->tenant_id) + ->whereKey($versionId) + ->first(); + + if (! $version) { + $service->recordFailure($run, (string) $versionId, 'Policy version not found'); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Restore Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if (! $version->trashed()) { + $service->recordSkippedWithReason($run, (string) $version->id, 'Not archived'); + $skipped++; + $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; + + continue; + } + + $version->restore(); + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $versionId, $e->getMessage()); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Restore Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if ($run->user) { + $message = "Restored {$succeeded} policy versions"; + if ($skipped > 0) { + $message .= " ({$skipped} skipped)"; + } + if ($failed > 0) { + $message .= " ({$failed} failed)"; + } + + if (! empty($skipReasons)) { + $summary = collect($skipReasons) + ->sortDesc() + ->map(fn (int $count, string $reason) => "{$reason} ({$count})") + ->take(3) + ->implode(', '); + + if ($summary !== '') { + $message .= " Skip reasons: {$summary}."; + } + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Restore Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } + } +} diff --git a/app/Livewire/BulkOperationProgress.php b/app/Livewire/BulkOperationProgress.php new file mode 100644 index 0000000..c8e6193 --- /dev/null +++ b/app/Livewire/BulkOperationProgress.php @@ -0,0 +1,47 @@ +loadRuns(); + } + + #[Computed] + public function activeRuns() + { + return $this->runs; + } + + public function loadRuns() + { + try { + $tenant = Tenant::current(); + } catch (\RuntimeException $e) { + $this->runs = collect(); + + return; + } + + $this->runs = BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', auth()->id()) + ->whereIn('status', ['pending', 'running']) + ->orderByDesc('created_at') + ->get(); + } + + public function render(): \Illuminate\Contracts\View\View + { + return view('livewire.bulk-operation-progress'); + } +} diff --git a/app/Models/BulkOperationRun.php b/app/Models/BulkOperationRun.php new file mode 100644 index 0000000..9342a26 --- /dev/null +++ b/app/Models/BulkOperationRun.php @@ -0,0 +1,53 @@ + 'array', + 'failures' => 'array', + 'processed_items' => 'integer', + 'total_items' => 'integer', + 'succeeded' => 'integer', + 'failed' => 'integer', + 'skipped' => 'integer', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function auditLog(): BelongsTo + { + return $this->belongsTo(AuditLog::class); + } +} diff --git a/app/Models/Policy.php b/app/Models/Policy.php index 61498d9..da28fd0 100644 --- a/app/Models/Policy.php +++ b/app/Models/Policy.php @@ -18,6 +18,7 @@ class Policy extends Model protected $casts = [ 'metadata' => 'array', 'last_synced_at' => 'datetime', + 'ignored_at' => 'datetime', ]; public function tenant(): BelongsTo @@ -34,4 +35,24 @@ public function backupItems(): HasMany { return $this->hasMany(BackupItem::class); } + + public function scopeActive($query) + { + return $query->whereNull('ignored_at'); + } + + public function scopeIgnored($query) + { + return $query->whereNotNull('ignored_at'); + } + + public function ignore(): void + { + $this->update(['ignored_at' => now()]); + } + + public function unignore(): void + { + $this->update(['ignored_at' => null]); + } } diff --git a/app/Models/PolicyVersion.php b/app/Models/PolicyVersion.php index ef8ef6e..4b97a9b 100644 --- a/app/Models/PolicyVersion.php +++ b/app/Models/PolicyVersion.php @@ -40,4 +40,14 @@ public function policy(): BelongsTo { return $this->belongsTo(Policy::class); } + + public function scopePruneEligible($query, int $days = 90) + { + return $query + ->whereNull('deleted_at') + ->where('captured_at', '<', now()->subDays($days)) + ->whereRaw( + 'policy_versions.version_number < (select max(pv2.version_number) from policy_versions pv2 where pv2.policy_id = policy_versions.policy_id and pv2.deleted_at is null)' + ); + } } diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index cd07138..244330c 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -33,4 +33,9 @@ public function backupSet(): BelongsTo { return $this->belongsTo(BackupSet::class); } + + public function scopeDeletable($query) + { + return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors']); + } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 7c5299e..9827abc 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -10,6 +10,7 @@ use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; +use Filament\View\PanelsRenderHook; use Filament\Widgets\AccountWidget; use Filament\Widgets\FilamentInfoWidget; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; @@ -31,6 +32,10 @@ public function panel(Panel $panel): Panel ->colors([ 'primary' => Color::Amber, ]) + ->renderHook( + PanelsRenderHook::BODY_END, + fn () => view('livewire.bulk-operation-progress-wrapper')->render() + ) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->pages([ @@ -41,6 +46,7 @@ public function panel(Panel $panel): Panel AccountWidget::class, FilamentInfoWidget::class, ]) + ->databaseNotifications() ->middleware([ EncryptCookies::class, AddQueuedCookiesToResponse::class, diff --git a/app/Services/BulkOperationService.php b/app/Services/BulkOperationService.php new file mode 100644 index 0000000..76b3af7 --- /dev/null +++ b/app/Services/BulkOperationService.php @@ -0,0 +1,189 @@ + $tenant->id, + 'user_id' => $user->id, + 'resource' => $resource, + 'action' => $action, + 'status' => 'pending', + 'item_ids' => $itemIds, + 'total_items' => $totalItems, + 'processed_items' => 0, + 'succeeded' => 0, + 'failed' => 0, + 'skipped' => 0, + 'failures' => [], + ]); + + $auditLog = $this->auditLogger->log( + tenant: $tenant, + action: "bulk.{$resource}.{$action}.created", + context: [ + 'metadata' => [ + 'bulk_run_id' => $run->id, + 'total_items' => $totalItems, + ], + ], + actorId: $user->id, + actorEmail: $user->email, + actorName: $user->name, + resourceType: 'bulk_operation_run', + resourceId: (string) $run->id + ); + + $run->update(['audit_log_id' => $auditLog->id]); + + return $run; + } + + public function start(BulkOperationRun $run): void + { + $run->update(['status' => 'running']); + } + + public function recordSuccess(BulkOperationRun $run): void + { + $run->increment('processed_items'); + $run->increment('succeeded'); + } + + public function recordFailure(BulkOperationRun $run, string $itemId, string $reason): void + { + $failures = $run->failures ?? []; + $failures[] = [ + 'item_id' => $itemId, + 'reason' => $reason, + 'timestamp' => now()->toIso8601String(), + ]; + + $run->update([ + 'failures' => $failures, + 'processed_items' => $run->processed_items + 1, + 'failed' => $run->failed + 1, + ]); + } + + public function recordSkipped(BulkOperationRun $run): void + { + $run->increment('processed_items'); + $run->increment('skipped'); + } + + public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, string $reason): void + { + $failures = $run->failures ?? []; + $failures[] = [ + 'item_id' => $itemId, + 'reason' => $reason, + 'type' => 'skipped', + 'timestamp' => now()->toIso8601String(), + ]; + + $run->update([ + 'failures' => $failures, + 'processed_items' => $run->processed_items + 1, + 'skipped' => $run->skipped + 1, + ]); + } + + public function complete(BulkOperationRun $run): void + { + $status = $run->failed > 0 ? 'completed_with_errors' : 'completed'; + $run->update(['status' => $status]); + + $failureEntries = collect($run->failures ?? []); + $failedReasons = $failureEntries + ->filter(fn (array $entry) => ($entry['type'] ?? 'failed') !== 'skipped') + ->groupBy('reason') + ->map(fn ($group) => $group->count()) + ->all(); + + $skippedReasons = $failureEntries + ->filter(fn (array $entry) => ($entry['type'] ?? null) === 'skipped') + ->groupBy('reason') + ->map(fn ($group) => $group->count()) + ->all(); + + $this->auditLogger->log( + tenant: $run->tenant, + action: "bulk.{$run->resource}.{$run->action}.{$status}", + context: [ + 'metadata' => [ + 'bulk_run_id' => $run->id, + 'succeeded' => $run->succeeded, + 'failed' => $run->failed, + 'skipped' => $run->skipped, + 'failed_reasons' => $failedReasons, + 'skipped_reasons' => $skippedReasons, + ], + ], + actorId: $run->user_id, + resourceType: 'bulk_operation_run', + resourceId: (string) $run->id + ); + } + + public function fail(BulkOperationRun $run, string $reason): void + { + $run->update(['status' => 'failed']); + + $this->auditLogger->log( + tenant: $run->tenant, + action: "bulk.{$run->resource}.{$run->action}.failed", + context: [ + 'reason' => $reason, + 'metadata' => [ + 'bulk_run_id' => $run->id, + ], + ], + actorId: $run->user_id, + status: 'failure', + resourceType: 'bulk_operation_run', + resourceId: (string) $run->id + ); + } + + public function abort(BulkOperationRun $run, string $reason): void + { + $run->update(['status' => 'aborted']); + + $this->auditLogger->log( + tenant: $run->tenant, + action: "bulk.{$run->resource}.{$run->action}.aborted", + context: [ + 'reason' => $reason, + 'metadata' => [ + 'bulk_run_id' => $run->id, + 'succeeded' => $run->succeeded, + 'failed' => $run->failed, + 'skipped' => $run->skipped, + ], + ], + actorId: $run->user_id, + status: 'failure', + resourceType: 'bulk_operation_run', + resourceId: (string) $run->id + ); + } +} diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index 399be6b..a920120 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -97,6 +97,7 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr 'display_name' => $displayName, 'platform' => $policyPlatform, 'last_synced_at' => now(), + 'ignored_at' => null, 'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']), ] ); diff --git a/database/factories/BulkOperationRunFactory.php b/database/factories/BulkOperationRunFactory.php new file mode 100644 index 0000000..55503f0 --- /dev/null +++ b/database/factories/BulkOperationRunFactory.php @@ -0,0 +1,34 @@ + + */ +class BulkOperationRunFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => \App\Models\Tenant::factory(), + 'user_id' => \App\Models\User::factory(), + 'resource' => 'policy', + 'action' => 'delete', + 'status' => 'pending', + 'total_items' => 10, + 'processed_items' => 0, + 'succeeded' => 0, + 'failed' => 0, + 'skipped' => 0, + 'item_ids' => range(1, 10), + 'failures' => [], + ]; + } +} diff --git a/database/factories/PolicyFactory.php b/database/factories/PolicyFactory.php new file mode 100644 index 0000000..2480d83 --- /dev/null +++ b/database/factories/PolicyFactory.php @@ -0,0 +1,28 @@ + + */ +class PolicyFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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'], + ]; + } +} diff --git a/database/factories/PolicyVersionFactory.php b/database/factories/PolicyVersionFactory.php new file mode 100644 index 0000000..0e1dde3 --- /dev/null +++ b/database/factories/PolicyVersionFactory.php @@ -0,0 +1,33 @@ + + */ +class PolicyVersionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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' => [], + ]; + } +} diff --git a/database/factories/TenantFactory.php b/database/factories/TenantFactory.php new file mode 100644 index 0000000..a1770ac --- /dev/null +++ b/database/factories/TenantFactory.php @@ -0,0 +1,27 @@ + + */ +class TenantFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->company(), + 'tenant_id' => fake()->uuid(), + 'external_id' => fake()->uuid(), + 'status' => 'active', + 'is_current' => true, + ]; + } +} diff --git a/database/migrations/2025_12_23_215901_create_bulk_operation_runs_table.php b/database/migrations/2025_12_23_215901_create_bulk_operation_runs_table.php new file mode 100644 index 0000000..18e0547 --- /dev/null +++ b/database/migrations/2025_12_23_215901_create_bulk_operation_runs_table.php @@ -0,0 +1,50 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('resource', 50); + $table->string('action', 50); + $table->string('status', 20)->default('pending'); + $table->unsignedInteger('total_items'); + $table->unsignedInteger('processed_items')->default(0); + $table->unsignedInteger('succeeded')->default(0); + $table->unsignedInteger('failed')->default(0); + $table->unsignedInteger('skipped')->default(0); + $table->jsonb('item_ids'); + $table->jsonb('failures')->nullable(); + $table->foreignId('audit_log_id')->nullable()->constrained()->nullOnDelete(); + $table->timestamps(); + }); + + Schema::table('bulk_operation_runs', function (Blueprint $table) { + $table->index(['tenant_id', 'resource', 'status'], 'bulk_runs_tenant_resource_status'); + $table->index(['user_id', 'created_at'], 'bulk_runs_user_created'); + }); + + DB::statement("CREATE INDEX bulk_runs_status_active ON bulk_operation_runs (status) WHERE status IN ('pending', 'running')"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::statement('DROP INDEX IF EXISTS bulk_runs_status_active'); + + Schema::dropIfExists('bulk_operation_runs'); + } +}; diff --git a/database/migrations/2025_12_23_215905_add_ignored_at_to_policies_table.php b/database/migrations/2025_12_23_215905_add_ignored_at_to_policies_table.php new file mode 100644 index 0000000..e8d8443 --- /dev/null +++ b/database/migrations/2025_12_23_215905_add_ignored_at_to_policies_table.php @@ -0,0 +1,28 @@ +timestamp('ignored_at')->nullable()->after('updated_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('policies', function (Blueprint $table) { + $table->dropColumn('ignored_at'); + }); + } +}; diff --git a/database/migrations/2025_12_24_002001_create_notifications_table.php b/database/migrations/2025_12_24_002001_create_notifications_table.php new file mode 100644 index 0000000..f09da33 --- /dev/null +++ b/database/migrations/2025_12_24_002001_create_notifications_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->jsonb('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2025_12_24_005055_increase_bulk_operation_runs_status_length.php b/database/migrations/2025_12_24_005055_increase_bulk_operation_runs_status_length.php new file mode 100644 index 0000000..1c558ec --- /dev/null +++ b/database/migrations/2025_12_24_005055_increase_bulk_operation_runs_status_length.php @@ -0,0 +1,28 @@ +string('status', 50)->default('pending')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('bulk_operation_runs', function (Blueprint $table) { + $table->string('status', 20)->default('pending')->change(); + }); + } +}; diff --git a/database/seeders/BulkOperationsTestSeeder.php b/database/seeders/BulkOperationsTestSeeder.php new file mode 100644 index 0000000..1229ac5 --- /dev/null +++ b/database/seeders/BulkOperationsTestSeeder.php @@ -0,0 +1,33 @@ +create(); + $user = \App\Models\User::first() ?? \App\Models\User::factory()->create(); + + // Create some policies to test bulk delete + \App\Models\Policy::factory()->count(30)->create([ + 'tenant_id' => $tenant->id, + 'policy_type' => 'deviceConfiguration', + ]); + + // Create a completed bulk run + \App\Models\BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'status' => 'completed', + 'total_items' => 10, + 'processed_items' => 10, + 'succeeded' => 10, + ]); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 996075e..1f9565b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,30 @@ services: - pgsql - redis + queue: + build: + context: ./vendor/laravel/sail/runtimes/8.4 + dockerfile: Dockerfile + args: + WWWGROUP: '${WWWGROUP:-1000}' + NODE_VERSION: '20' + image: tenantatlas-laravel + extra_hosts: + - 'host.docker.internal:host-gateway' + environment: + WWWUSER: '${WWWUSER:-1000}' + LARAVEL_SAIL: 1 + APP_SERVICE: queue + volumes: + - '.:/var/www/html' + networks: + - sail + depends_on: + - laravel.test + - pgsql + - redis + command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000 + pgsql: image: 'postgres:16' ports: diff --git a/resources/views/livewire/bulk-operation-progress-wrapper.blade.php b/resources/views/livewire/bulk-operation-progress-wrapper.blade.php new file mode 100644 index 0000000..69bcec8 --- /dev/null +++ b/resources/views/livewire/bulk-operation-progress-wrapper.blade.php @@ -0,0 +1 @@ + diff --git a/resources/views/livewire/bulk-operation-progress.blade.php b/resources/views/livewire/bulk-operation-progress.blade.php new file mode 100644 index 0000000..18c0289 --- /dev/null +++ b/resources/views/livewire/bulk-operation-progress.blade.php @@ -0,0 +1,75 @@ +
+ + @if($runs->isNotEmpty()) +
+ @foreach ($runs as $run) +
+ +
+
+

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

+

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

+
+
+ + {{ $run->processed_items }} / {{ $run->total_items }} + +
+ {{ $run->total_items > 0 ? round(($run->processed_items / $run->total_items) * 100) : 0 }}% +
+
+
+ +
+
+
+ +
+
+ @if ($run->succeeded > 0) + + ✓ {{ $run->succeeded }} succeeded + + @endif + @if ($run->failed > 0) + + ✗ {{ $run->failed }} failed + + @endif + @if ($run->skipped > 0) + + ⊘ {{ $run->skipped }} skipped + + @endif +
+ + {{ $run->created_at->diffForHumans(null, true, true) }} + +
+
+ @endforeach +
+ @endif +
diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md index 1e5ff4d..9e5e7b8 100644 --- a/specs/005-bulk-operations/tasks.md +++ b/specs/005-bulk-operations/tasks.md @@ -15,12 +15,12 @@ ## Phase 1: Setup (Project Initialization) **Purpose**: Database schema and base infrastructure for bulk operations -- [ ] T001 Create migration for `bulk_operation_runs` table in database/migrations/YYYY_MM_DD_HHMMSS_create_bulk_operation_runs_table.php -- [ ] T002 Create migration to add `ignored_at` column to policies table in database/migrations/YYYY_MM_DD_HHMMSS_add_ignored_at_to_policies_table.php -- [ ] T003 [P] Create BulkOperationRun model in app/Models/BulkOperationRun.php -- [ ] T004 [P] Create BulkOperationService in app/Services/BulkOperationService.php -- [ ] T005 Run migrations and verify schema: `./vendor/bin/sail artisan migrate` -- [ ] T006 Run Pint formatting: `./vendor/bin/sail composer pint` +- [x] T001 Create migration for `bulk_operation_runs` table in database/migrations/YYYY_MM_DD_HHMMSS_create_bulk_operation_runs_table.php +- [x] T002 Create migration to add `ignored_at` column to policies table in database/migrations/YYYY_MM_DD_HHMMSS_add_ignored_at_to_policies_table.php +- [x] T003 [P] Create BulkOperationRun model in app/Models/BulkOperationRun.php +- [x] T004 [P] Create BulkOperationService in app/Services/BulkOperationService.php +- [x] T005 Run migrations and verify schema: `./vendor/bin/sail artisan migrate` +- [x] T006 Run Pint formatting: `./vendor/bin/sail composer pint` **Checkpoint**: Database ready, base models created @@ -32,13 +32,13 @@ ## Phase 2: Foundational (Shared Components) **⚠️ CRITICAL**: No user story work can begin until this phase is complete -- [ ] T007 Extend Policy model with `ignored_at` scope and methods in app/Models/Policy.php -- [ ] T008 [P] Extend PolicyVersion model with `pruneEligible()` scope in app/Models/PolicyVersion.php -- [ ] T009 [P] Extend RestoreRun model with `deletable()` scope in app/Models/RestoreRun.php -- [ ] T010 Extend AuditLogger service to support bulk operation events in app/Services/Audit/AuditLogger.php (or equivalent) -- [ ] T011 Update SyncPoliciesJob to filter by `ignored_at IS NULL` in app/Jobs/SyncPoliciesJob.php -- [ ] T012 Create BulkOperationRun factory in database/factories/BulkOperationRunFactory.php -- [ ] T013 Create test seeder BulkOperationsTestSeeder in database/seeders/BulkOperationsTestSeeder.php +- [x] T007 Extend Policy model with `ignored_at` scope and methods in app/Models/Policy.php +- [x] T008 [P] Extend PolicyVersion model with `pruneEligible()` scope in app/Models/PolicyVersion.php +- [x] T009 [P] Extend RestoreRun model with `deletable()` scope in app/Models/RestoreRun.php +- [x] T010 Extend AuditLogger service to support bulk operation events in app/Services/Audit/AuditLogger.php (or equivalent) +- [x] T011 Update SyncPoliciesJob to filter by `ignored_at IS NULL` in app/Jobs/SyncPoliciesJob.php +- [x] T012 Create BulkOperationRun factory in database/factories/BulkOperationRunFactory.php +- [x] T013 Create test seeder BulkOperationsTestSeeder in database/seeders/BulkOperationsTestSeeder.php **Checkpoint**: Foundation ready - user story implementation can now begin in parallel @@ -52,21 +52,21 @@ ## Phase 3: User Story 1 - Bulk Delete Policies (Priority: P1) 🎯 MVP ### Tests for User Story 1 -- [ ] T014 [P] [US1] Write unit test for BulkPolicyDeleteJob in tests/Unit/BulkPolicyDeleteJobTest.php -- [ ] T015 [P] [US1] Write feature test for bulk delete <20 items (sync) in tests/Feature/BulkDeletePoliciesTest.php -- [ ] T016 [P] [US1] Write feature test for bulk delete ≥20 items (async) in tests/Feature/BulkDeletePoliciesAsyncTest.php -- [ ] T017 [P] [US1] Write permission test for bulk delete in tests/Unit/BulkActionPermissionTest.php +- [x] T014 [P] [US1] Write unit test for BulkPolicyDeleteJob in tests/Unit/BulkPolicyDeleteJobTest.php +- [x] T015 [P] [US1] Write feature test for bulk delete <20 items (sync) in tests/Feature/BulkDeletePoliciesTest.php +- [x] T016 [P] [US1] Write feature test for bulk delete ≥20 items (async) in tests/Feature/BulkDeletePoliciesAsyncTest.php +- [x] T017 [P] [US1] Write permission test for bulk delete in tests/Unit/BulkActionPermissionTest.php ### Implementation for User Story 1 -- [ ] T018 [P] [US1] Create BulkPolicyDeleteJob in app/Jobs/BulkPolicyDeleteJob.php -- [ ] T019 [US1] Add bulk delete action to PolicyResource in app/Filament/Resources/PolicyResource.php -- [ ] T020 [US1] Implement type-to-confirm modal (≥20 items) in PolicyResource bulk action -- [ ] T021 [US1] Wire BulkOperationService to create tracking record and dispatch job -- [ ] T022 [US1] Test bulk delete with 10 policies (sync, manual QA) -- [ ] T023 [US1] Test bulk delete with 25 policies (async, manual QA) -- [ ] T024 [US1] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeletePoliciesTest.php` -- [ ] T025 [US1] Verify audit log entry created with correct metadata +- [x] T018 [P] [US1] Create BulkPolicyDeleteJob in app/Jobs/BulkPolicyDeleteJob.php +- [x] T019 [US1] Add bulk delete action to PolicyResource in app/Filament/Resources/PolicyResource.php +- [x] T020 [US1] Implement type-to-confirm modal (≥20 items) in PolicyResource bulk action +- [x] T021 [US1] Wire BulkOperationService to create tracking record and dispatch job +- [x] T022 [US1] Test bulk delete with 10 policies (sync, manual QA) +- [x] T023 [US1] Test bulk delete with 25 policies (async, manual QA) +- [x] T024 [US1] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeletePoliciesTest.php` +- [x] T025 [US1] Verify audit log entry created with correct metadata **Checkpoint**: Bulk delete policies working (sync + async), audit logged, tests passing @@ -80,19 +80,19 @@ ## Phase 4: User Story 2 - Bulk Export Policies to Backup (Priority: P1) ### Tests for User Story 2 -- [ ] T026 [P] [US2] Write unit test for BulkPolicyExportJob in tests/Unit/BulkPolicyExportJobTest.php -- [ ] T027 [P] [US2] Write feature test for bulk export in tests/Feature/BulkExportToBackupTest.php -- [ ] T028 [P] [US2] Write test for export with failures (3/25 fail) in tests/Feature/BulkExportFailuresTest.php +- [x] T026 [P] [US2] Write unit test for BulkPolicyExportJob in tests/Unit/BulkPolicyExportJobTest.php +- [x] T027 [P] [US2] Write feature test for bulk export in tests/Feature/BulkExportToBackupTest.php +- [x] T028 [P] [US2] Write test for export with failures (3/25 fail) in tests/Feature/BulkExportFailuresTest.php ### Implementation for User Story 2 -- [ ] T029 [P] [US2] Create BulkPolicyExportJob in app/Jobs/BulkPolicyExportJob.php -- [ ] T030 [US2] Add bulk export action to PolicyResource in app/Filament/Resources/PolicyResource.php -- [ ] T031 [US2] Create export form with backup_name and include_assignments fields -- [ ] T032 [US2] Implement export logic (create BackupSet, capture BackupItems) -- [ ] T033 [US2] Handle partial failures (some policies fail to backup) -- [ ] T034 [US2] Test export with 30 policies (manual QA) -- [ ] T035 [US2] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkExportToBackupTest.php` +- [x] T029 [P] [US2] Create BulkPolicyExportJob in app/Jobs/BulkPolicyExportJob.php +- [x] T030 [US2] Add bulk export action to PolicyResource in app/Filament/Resources/PolicyResource.php +- [x] T031 [US2] Create export form with backup_name and include_assignments fields +- [x] T032 [US2] Implement export logic (create BackupSet, capture BackupItems) +- [x] T033 [US2] Handle partial failures (some policies fail to backup) +- [x] T034 [US2] Test export with 30 policies (manual QA) +- [x] T035 [US2] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkExportToBackupTest.php` **Checkpoint**: Bulk export working, BackupSets created, failures handled gracefully @@ -108,9 +108,9 @@ ## Phase 5: User Story 5 - Type-to-Confirm (Priority: P1) ### Tests for User Story 5 -- [ ] T036 [P] [US5] Write test for type-to-confirm with correct input in tests/Feature/BulkTypeToConfirmTest.php -- [ ] T037 [P] [US5] Write test for type-to-confirm with incorrect input (lowercase "delete") -- [ ] T038 [P] [US5] Write test for type-to-confirm disabled for <20 items +- [x] T036 [P] [US5] Write test for type-to-confirm with correct input in tests/Feature/BulkTypeToConfirmTest.php +- [x] T037 [P] [US5] Write test for type-to-confirm with incorrect input (lowercase "delete") +- [x] T038 [P] [US5] Write test for type-to-confirm disabled for <20 items ### Validation for User Story 5 @@ -131,18 +131,18 @@ ## Phase 6: User Story 6 - Progress Tracking (Priority: P2) ### Tests for User Story 6 -- [ ] T043 [P] [US6] Write test for progress updates in BulkOperationRun in tests/Unit/BulkOperationRunProgressTest.php -- [ ] T044 [P] [US6] Write feature test for progress notification in tests/Feature/BulkProgressNotificationTest.php -- [ ] T045 [P] [US6] Write test for circuit breaker (abort >50% fail) in tests/Unit/CircuitBreakerTest.php +- [x] T043 [P] [US6] Write test for progress updates in BulkOperationRun in tests/Unit/BulkOperationRunProgressTest.php +- [x] T044 [P] [US6] Write feature test for progress notification in tests/Feature/BulkProgressNotificationTest.php +- [x] T045 [P] [US6] Write test for circuit breaker (abort >50% fail) in tests/Unit/CircuitBreakerTest.php ### Implementation for User Story 6 -- [ ] T046 [P] [US6] Create Livewire progress component in app/Livewire/BulkOperationProgress.php -- [ ] T047 [P] [US6] Create progress view in resources/views/livewire/bulk-operation-progress.blade.php -- [ ] T048 [US6] Update BulkPolicyDeleteJob to emit progress after each chunk -- [ ] T049 [US6] Update BulkPolicyExportJob to emit progress after each chunk -- [ ] T050 [US6] Implement circuit breaker logic (abort if >50% fail) in jobs -- [ ] T051 [US6] Add progress polling to Filament notifications or sidebar widget +- [x] T046 [P] [US6] Create Livewire progress component in app/Livewire/BulkOperationProgress.php +- [x] T047 [P] [US6] Create progress view in resources/views/livewire/bulk-operation-progress.blade.php +- [x] T048 [US6] Update BulkPolicyDeleteJob to emit progress after each chunk +- [x] T049 [US6] Update BulkPolicyExportJob to emit progress after each chunk +- [x] T050 [US6] Implement circuit breaker logic (abort if >50% fail) in jobs +- [x] T051 [US6] Add progress polling to Filament notifications or sidebar widget - [ ] T052 [US6] Test progress with 100 policies (manual QA, observe updates) - [ ] T053 [US6] Test circuit breaker with mock failures (manual QA) - [ ] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php` @@ -159,21 +159,29 @@ ## Phase 7: User Story 3 - Bulk Delete Policy Versions (Priority: P2) ### Tests for User Story 3 -- [ ] T055 [P] [US3] Write unit test for pruneEligible() scope in tests/Unit/PolicyVersionEligibilityTest.php -- [ ] T056 [P] [US3] Write unit test for BulkPolicyVersionPruneJob in tests/Unit/BulkPolicyVersionPruneJobTest.php -- [ ] T057 [P] [US3] Write feature test for bulk prune in tests/Feature/BulkPruneVersionsTest.php -- [ ] T058 [P] [US3] Write test for skip reasons (referenced, too recent, current) in tests/Feature/BulkPruneSkipReasonsTest.php +- [x] T055 [P] [US3] Write unit test for pruneEligible() scope in tests/Unit/PolicyVersionEligibilityTest.php +- [x] T056 [P] [US3] Write unit test for BulkPolicyVersionPruneJob in tests/Unit/BulkPolicyVersionPruneJobTest.php +- [x] T057 [P] [US3] Write feature test for bulk prune in tests/Feature/BulkPruneVersionsTest.php +- [x] T058 [P] [US3] Write test for skip reasons (referenced, too recent, current) in tests/Feature/BulkPruneSkipReasonsTest.php ### Implementation for User Story 3 -- [ ] T059 [P] [US3] Create BulkPolicyVersionPruneJob in app/Jobs/BulkPolicyVersionPruneJob.php -- [ ] T060 [US3] Add bulk delete action to PolicyVersionResource in app/Filament/Resources/PolicyVersionResource.php -- [ ] T061 [US3] Implement eligibility check per version in job (reuse pruneEligible scope) -- [ ] T062 [US3] Collect skip reasons for ineligible versions -- [ ] T063 [US3] Add type-to-confirm for ≥20 versions +- [x] T059 [P] [US3] Create BulkPolicyVersionPruneJob in app/Jobs/BulkPolicyVersionPruneJob.php +- [x] T060 [US3] Add bulk delete action to PolicyVersionResource in app/Filament/Resources/PolicyVersionResource.php +- [x] T061 [US3] Implement eligibility check per version in job (reuse pruneEligible scope) +- [x] T062 [US3] Collect skip reasons for ineligible versions +- [x] T063 [US3] Add type-to-confirm for ≥20 versions - [ ] T064 [US3] Test prune with 30 versions (15 eligible, 15 ineligible) - manual QA -- [ ] T065 [US3] Verify skip reasons in notification and audit log -- [ ] T066 [US3] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkPruneVersionsTest.php` +- [x] T065 [US3] Verify skip reasons in notification and audit log +- [x] T066 [US3] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkPruneVersionsTest.php` + +- [x] T066a [US3] Add bulk force delete versions job in app/Jobs/BulkPolicyVersionForceDeleteJob.php +- [x] T066b [US3] Add bulk force delete action to PolicyVersionResource in app/Filament/Resources/PolicyVersionResource.php +- [x] T066c [US3] Write unit+feature tests for bulk force delete in tests/Unit/BulkPolicyVersionForceDeleteJobTest.php and tests/Feature/BulkForceDeletePolicyVersionsTest.php + +- [x] T066d [US3] Add bulk restore versions job in app/Jobs/BulkPolicyVersionRestoreJob.php +- [x] T066e [US3] Add restore actions (row + bulk) to PolicyVersionResource in app/Filament/Resources/PolicyVersionResource.php +- [x] T066f [US3] Write unit+feature tests for bulk restore in tests/Unit/BulkPolicyVersionRestoreJobTest.php and tests/Feature/BulkRestorePolicyVersionsTest.php **Checkpoint**: Policy versions pruning working, eligibility enforced, skip reasons logged diff --git a/tests/Feature/BulkDeletePoliciesAsyncTest.php b/tests/Feature/BulkDeletePoliciesAsyncTest.php new file mode 100644 index 0000000..754b094 --- /dev/null +++ b/tests/Feature/BulkDeletePoliciesAsyncTest.php @@ -0,0 +1,30 @@ +create(); + $user = User::factory()->create(); + $policies = Policy::factory()->count(25)->create(['tenant_id' => $tenant->id]); + $policyIds = $policies->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 25); + + // Simulate Async dispatch (this logic will be in Filament Action) + BulkPolicyDeleteJob::dispatch($run->id); + + Queue::assertPushed(BulkPolicyDeleteJob::class, function ($job) use ($run) { + return $job->bulkRunId === $run->id; + }); +}); diff --git a/tests/Feature/BulkDeletePoliciesTest.php b/tests/Feature/BulkDeletePoliciesTest.php new file mode 100644 index 0000000..4a2c72f --- /dev/null +++ b/tests/Feature/BulkDeletePoliciesTest.php @@ -0,0 +1,34 @@ +create(); + $user = User::factory()->create(); + $policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]); + $policyIds = $policies->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 10); + + // Simulate Sync execution + BulkPolicyDeleteJob::dispatchSync($run->id); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->processed_items)->toBe(10) + ->and($run->audit_log_id)->not->toBeNull(); + + expect(\App\Models\AuditLog::where('action', 'bulk.policy.delete.completed')->exists())->toBeTrue(); + + $policies->each(function ($policy) { + expect($policy->refresh()->ignored_at)->not->toBeNull(); + }); +}); diff --git a/tests/Feature/BulkExportFailuresTest.php b/tests/Feature/BulkExportFailuresTest.php new file mode 100644 index 0000000..3fd0cf7 --- /dev/null +++ b/tests/Feature/BulkExportFailuresTest.php @@ -0,0 +1,51 @@ +create(); + $user = User::factory()->create(); + + $okPolicy = Policy::factory()->create(['tenant_id' => $tenant->id]); + PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $okPolicy->id, + 'policy_type' => $okPolicy->policy_type, + 'version_number' => 1, + 'snapshot' => ['ok' => true], + 'captured_at' => now(), + ]); + + $missingVersionPolicy = Policy::factory()->create(['tenant_id' => $tenant->id]); + + $service = app(BulkOperationService::class); + $run = $service->createRun( + $tenant, + $user, + 'policy', + 'export', + [$okPolicy->id, $missingVersionPolicy->id], + 2 + ); + + (new BulkPolicyExportJob($run->id, 'Failures Backup'))->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed_with_errors') + ->and($run->succeeded)->toBe(1) + ->and($run->failed)->toBe(1) + ->and($run->processed_items)->toBe(2); + + $this->assertDatabaseHas('backup_sets', [ + 'tenant_id' => $tenant->id, + 'name' => 'Failures Backup', + ]); +}); diff --git a/tests/Feature/BulkExportToBackupTest.php b/tests/Feature/BulkExportToBackupTest.php new file mode 100644 index 0000000..e315aea --- /dev/null +++ b/tests/Feature/BulkExportToBackupTest.php @@ -0,0 +1,41 @@ +create(); + $user = User::factory()->create(); + + $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); + PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'policy_type' => $policy->policy_type, + 'version_number' => 1, + 'snapshot' => ['test' => 'data'], + 'captured_at' => now(), + ]); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'export', [$policy->id], 1); + + // Simulate Sync + $job = new BulkPolicyExportJob($run->id, 'Feature Backup'); + $job->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed'); + + $this->assertDatabaseHas('backup_sets', [ + 'name' => 'Feature Backup', + 'tenant_id' => $tenant->id, + ]); +}); diff --git a/tests/Feature/BulkForceDeletePolicyVersionsTest.php b/tests/Feature/BulkForceDeletePolicyVersionsTest.php new file mode 100644 index 0000000..106239b --- /dev/null +++ b/tests/Feature/BulkForceDeletePolicyVersionsTest.php @@ -0,0 +1,49 @@ +create(['is_current' => true]); + $user = User::factory()->create(); + + $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(120), + ]); + $version->delete(); + + Livewire::actingAs($user) + ->test(PolicyVersionResource\Pages\ListPolicyVersions::class) + ->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false) + ->callTableBulkAction('bulk_force_delete_versions', collect([$version]), data: [ + 'confirmation' => 'DELETE', + ]) + ->assertHasNoTableBulkActionErrors(); + + $run = BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('resource', 'policy_version') + ->where('action', 'force_delete') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run->succeeded)->toBe(1) + ->and($run->skipped)->toBe(0) + ->and($run->failed)->toBe(0); + + expect(PolicyVersion::withTrashed()->whereKey($version->id)->exists())->toBeFalse(); +}); diff --git a/tests/Feature/BulkProgressNotificationTest.php b/tests/Feature/BulkProgressNotificationTest.php new file mode 100644 index 0000000..d01960e --- /dev/null +++ b/tests/Feature/BulkProgressNotificationTest.php @@ -0,0 +1,51 @@ +create(); + $user = User::factory()->create(); + + // Own running op + BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'status' => 'running', + 'resource' => 'policy', + 'action' => 'delete', + 'total_items' => 100, + 'processed_items' => 50, + ]); + + // Completed op (should not show) + BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'status' => 'completed', + ]); + + // Other user's op (should not show) + $otherUser = User::factory()->create(); + BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $otherUser->id, + 'status' => 'running', + ]); + + // $tenant->makeCurrent(); + $tenant->forceFill(['is_current' => true])->save(); + + auth()->login($user); // Login user explicitly for auth()->id() call in component + + Livewire::actingAs($user) + ->test(BulkOperationProgress::class) + ->assertSee('Delete Policy') + ->assertSee('50 / 100'); +}); diff --git a/tests/Feature/BulkPruneSkipReasonsTest.php b/tests/Feature/BulkPruneSkipReasonsTest.php new file mode 100644 index 0000000..759ff54 --- /dev/null +++ b/tests/Feature/BulkPruneSkipReasonsTest.php @@ -0,0 +1,61 @@ +create(); + $user = User::factory()->create(); + + $policyA = Policy::factory()->create(['tenant_id' => $tenant->id]); + $current = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policyA->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(120), + ]); + + $policyB = Policy::factory()->create(['tenant_id' => $tenant->id]); + $tooRecent = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policyB->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(10), + ]); + PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policyB->id, + 'version_number' => 2, + 'captured_at' => now()->subDays(10), + ]); + + $tenant->forceFill(['is_current' => true])->save(); + + Livewire::actingAs($user) + ->test(PolicyVersionResource\Pages\ListPolicyVersions::class) + ->callTableBulkAction('bulk_prune_versions', collect([$current, $tooRecent]), data: [ + 'retention_days' => 90, + ]) + ->assertHasNoTableBulkActionErrors(); + + $run = BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('resource', 'policy_version') + ->where('action', 'prune') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + $reasons = collect($run->failures ?? [])->pluck('reason')->all(); + expect($reasons)->toContain('Current version') + ->and($reasons)->toContain('Too recent'); +}); diff --git a/tests/Feature/BulkPruneVersionsTest.php b/tests/Feature/BulkPruneVersionsTest.php new file mode 100644 index 0000000..ec62444 --- /dev/null +++ b/tests/Feature/BulkPruneVersionsTest.php @@ -0,0 +1,44 @@ +create(); + $user = User::factory()->create(); + + $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); + + $eligible = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(120), + ]); + + $current = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 2, + 'captured_at' => now()->subDays(120), + ]); + + $tenant->forceFill(['is_current' => true])->save(); + + Livewire::actingAs($user) + ->test(PolicyVersionResource\Pages\ListPolicyVersions::class) + ->callTableBulkAction('bulk_prune_versions', collect([$eligible, $current]), data: [ + 'retention_days' => 90, + ]) + ->assertHasNoTableBulkActionErrors(); + + expect($eligible->refresh()->trashed())->toBeTrue(); + expect($current->refresh()->trashed())->toBeFalse(); +}); diff --git a/tests/Feature/BulkRestorePolicyVersionsTest.php b/tests/Feature/BulkRestorePolicyVersionsTest.php new file mode 100644 index 0000000..41d6a81 --- /dev/null +++ b/tests/Feature/BulkRestorePolicyVersionsTest.php @@ -0,0 +1,48 @@ +create(['is_current' => true]); + $user = User::factory()->create(); + + $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(120), + ]); + $version->delete(); + + Livewire::actingAs($user) + ->test(PolicyVersionResource\Pages\ListPolicyVersions::class) + ->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false) + ->callTableBulkAction('bulk_restore_versions', collect([$version])) + ->assertHasNoTableBulkActionErrors(); + + $run = BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('resource', 'policy_version') + ->where('action', 'restore') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run->succeeded)->toBe(1) + ->and($run->skipped)->toBe(0) + ->and($run->failed)->toBe(0); + + $version->refresh(); + expect($version->trashed())->toBeFalse(); +}); diff --git a/tests/Feature/BulkTypeToConfirmTest.php b/tests/Feature/BulkTypeToConfirmTest.php new file mode 100644 index 0000000..978748a --- /dev/null +++ b/tests/Feature/BulkTypeToConfirmTest.php @@ -0,0 +1,53 @@ +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()); +}); diff --git a/tests/Feature/BulkUnignorePoliciesTest.php b/tests/Feature/BulkUnignorePoliciesTest.php new file mode 100644 index 0000000..fa53b64 --- /dev/null +++ b/tests/Feature/BulkUnignorePoliciesTest.php @@ -0,0 +1,41 @@ +create(); + $user = User::factory()->create(); + + $policies = Policy::factory() + ->count(5) + ->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => now(), + ]); + + $policyIds = $policies->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'unignore', $policyIds, count($policyIds)); + + BulkPolicyUnignoreJob::dispatchSync($run->id); + + $run->refresh(); + + expect($run->status)->toBe('completed') + ->and($run->processed_items)->toBe(5) + ->and($run->audit_log_id)->not->toBeNull(); + + expect(\App\Models\AuditLog::where('action', 'bulk.policy.unignore.completed')->exists())->toBeTrue(); + + $policies->each(function (Policy $policy): void { + expect($policy->refresh()->ignored_at)->toBeNull(); + }); +}); diff --git a/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php b/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php new file mode 100644 index 0000000..d6a50ab --- /dev/null +++ b/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php @@ -0,0 +1,159 @@ + $responses + */ + public function __construct(private array $responses = []) {} + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return $this->responses[$policyType] ?? new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +test('sync revives ignored policies when they exist in Intune', function () { + $tenant = Tenant::create([ + 'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'), + 'name' => 'Test Tenant', + 'metadata' => [], + 'is_current' => true, + ]); + + // Create an ignored policy + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-123', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Test Policy', + 'platform' => 'windows', + 'ignored_at' => now(), + ]); + + expect($policy->ignored_at)->not->toBeNull(); + + // Mock Graph response with the same policy + $responses = [ + 'deviceConfiguration' => new GraphResponse(true, [ + [ + 'id' => 'policy-123', + 'displayName' => 'Test Policy (Updated)', + 'platform' => 'windows', + ], + ]), + ]; + + app()->instance(GraphClientInterface::class, new FakeGraphClientForSync($responses)); + + // Sync policies + app(PolicySyncService::class)->syncPolicies($tenant); + + // Refresh the policy + $policy->refresh(); + + // Policy should no longer be ignored + expect($policy->ignored_at)->toBeNull(); + expect($policy->display_name)->toBe('Test Policy (Updated)'); + expect($policy->last_synced_at)->not->toBeNull(); +}); + +test('sync creates new policies even if ignored ones exist with same external_id', function () { + $tenant = Tenant::create([ + 'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant-2'), + 'name' => 'Test Tenant 2', + 'metadata' => [], + 'is_current' => true, + ]); + + // Create multiple ignored policies + Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-abc', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Old Policy ABC', + 'platform' => 'windows', + 'ignored_at' => now()->subDay(), + ]); + + Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-def', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Old Policy DEF', + 'platform' => 'android', + 'ignored_at' => now()->subDay(), + ]); + + expect(Policy::active()->count())->toBe(0); + expect(Policy::ignored()->count())->toBe(2); + + // Mock Graph response with same policy IDs but potentially different data + $responses = [ + 'deviceConfiguration' => new GraphResponse(true, [ + [ + 'id' => 'policy-abc', + 'displayName' => 'Restored Policy ABC', + 'platform' => 'windows', + ], + ]), + 'deviceCompliancePolicy' => new GraphResponse(true, [ + [ + 'id' => 'policy-def', + 'displayName' => 'Restored Policy DEF', + 'platform' => 'android', + ], + ]), + ]; + + app()->instance(GraphClientInterface::class, new FakeGraphClientForSync($responses)); + + // Sync policies + app(PolicySyncService::class)->syncPolicies($tenant); + + // All policies should now be active + expect(Policy::active()->count())->toBe(2); + expect(Policy::ignored()->count())->toBe(0); + + $policyAbc = Policy::where('external_id', 'policy-abc')->first(); + expect($policyAbc->display_name)->toBe('Restored Policy ABC'); + expect($policyAbc->ignored_at)->toBeNull(); + + $policyDef = Policy::where('external_id', 'policy-def')->first(); + expect($policyDef->display_name)->toBe('Restored Policy DEF'); + expect($policyDef->ignored_at)->toBeNull(); +}); diff --git a/tests/Unit/BulkActionPermissionTest.php b/tests/Unit/BulkActionPermissionTest.php new file mode 100644 index 0000000..60d3c04 --- /dev/null +++ b/tests/Unit/BulkActionPermissionTest.php @@ -0,0 +1,14 @@ +toBeTrue(); +}); diff --git a/tests/Unit/BulkOperationAbortMethodTest.php b/tests/Unit/BulkOperationAbortMethodTest.php new file mode 100644 index 0000000..de3b92d --- /dev/null +++ b/tests/Unit/BulkOperationAbortMethodTest.php @@ -0,0 +1,24 @@ +create(); + $user = User::factory()->create(); + + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'status' => 'running', + ]); + + app(BulkOperationService::class)->abort($run, 'threshold exceeded'); + + expect($run->refresh()->status)->toBe('aborted'); +}); diff --git a/tests/Unit/BulkOperationRunProgressTest.php b/tests/Unit/BulkOperationRunProgressTest.php new file mode 100644 index 0000000..039b52f --- /dev/null +++ b/tests/Unit/BulkOperationRunProgressTest.php @@ -0,0 +1,33 @@ +create(); + $user = User::factory()->create(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'delete', [1, 2, 3], 3); + + $service->start($run); + + $service->recordSuccess($run); + $service->recordSkipped($run); + $service->recordFailure($run, '3', 'Test failure'); + + $run->refresh(); + + expect($run->status)->toBe('running') + ->and($run->processed_items)->toBe(3) + ->and($run->succeeded)->toBe(1) + ->and($run->skipped)->toBe(1) + ->and($run->failed)->toBe(1) + ->and($run->failures)->toBeArray() + ->and($run->failures)->toHaveCount(1); +}); diff --git a/tests/Unit/BulkPolicyDeleteJobTest.php b/tests/Unit/BulkPolicyDeleteJobTest.php new file mode 100644 index 0000000..37ca156 --- /dev/null +++ b/tests/Unit/BulkPolicyDeleteJobTest.php @@ -0,0 +1,61 @@ +create(); + $user = User::factory()->create(); + + $policies = Policy::factory()->count(3)->create(['tenant_id' => $tenant->id]); + $policyIds = $policies->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 3); + + $job = new BulkPolicyDeleteJob($run->id); + $job->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->processed_items)->toBe(3) + ->and($run->succeeded)->toBe(3) + ->and($run->failed)->toBe(0); + + $policies->each(function ($policy) { + expect($policy->refresh()->ignored_at)->not->toBeNull(); + }); +}); + +test('job handles partial failures gracefully', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $policies = Policy::factory()->count(2)->create(['tenant_id' => $tenant->id]); + $policyIds = $policies->pluck('id')->toArray(); + + // Add a non-existent ID + $policyIds[] = 99999; + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 3); + + $job = new BulkPolicyDeleteJob($run->id); + $job->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed_with_errors') + ->and($run->processed_items)->toBe(3) + ->and($run->succeeded)->toBe(2) + ->and($run->failed)->toBe(1); + + expect($run->failures[0]['item_id'])->toBe('99999') + ->and($run->failures[0]['reason'])->toContain('not found'); +}); diff --git a/tests/Unit/BulkPolicyExportJobTest.php b/tests/Unit/BulkPolicyExportJobTest.php new file mode 100644 index 0000000..88dfb9a --- /dev/null +++ b/tests/Unit/BulkPolicyExportJobTest.php @@ -0,0 +1,88 @@ +create(); + $user = User::factory()->create(); + + $policies = Policy::factory()->count(3)->create(['tenant_id' => $tenant->id]); + + // Create versions for policies so they can be backed up + $policies->each(function ($policy) use ($tenant) { + PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'policy_type' => $policy->policy_type, + 'version_number' => 1, + 'snapshot' => ['name' => $policy->display_name], + 'captured_at' => now(), + ]); + }); + + $policyIds = $policies->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'export', $policyIds, 3); + + $job = new BulkPolicyExportJob($run->id, 'My Bulk Backup'); + $job->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->processed_items)->toBe(3) + ->and($run->succeeded)->toBe(3); + + // Verify BackupSet created + $backupSet = BackupSet::where('name', 'My Bulk Backup')->first(); + expect($backupSet)->not->toBeNull() + ->and($backupSet->tenant_id)->toBe($tenant->id); + + // Verify BackupItems created + expect(BackupItem::where('backup_set_id', $backupSet->id)->count())->toBe(3); +}); + +test('job handles policies without versions gracefully', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $policyWithVersion = Policy::factory()->create(['tenant_id' => $tenant->id]); + PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policyWithVersion->id, + 'policy_type' => $policyWithVersion->policy_type, + 'version_number' => 1, + 'snapshot' => ['name' => 'ok'], + 'captured_at' => now(), + ]); + + $policyNoVersion = Policy::factory()->create(['tenant_id' => $tenant->id]); + + $policyIds = [$policyWithVersion->id, $policyNoVersion->id]; + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'export', $policyIds, 2); + + $job = new BulkPolicyExportJob($run->id, 'Partial Backup'); + $job->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed_with_errors') + ->and($run->processed_items)->toBe(2) + ->and($run->succeeded)->toBe(1) + ->and($run->failed)->toBe(1); + + expect($run->failures[0]['item_id'])->toBe((string) $policyNoVersion->id) + ->and($run->failures[0]['reason'])->toContain('No versions available'); +}); diff --git a/tests/Unit/BulkPolicyVersionForceDeleteJobTest.php b/tests/Unit/BulkPolicyVersionForceDeleteJobTest.php new file mode 100644 index 0000000..a90f55e --- /dev/null +++ b/tests/Unit/BulkPolicyVersionForceDeleteJobTest.php @@ -0,0 +1,69 @@ +create(); + $user = User::factory()->create(); + + $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); + + $archived = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(120), + ]); + $archived->delete(); + + $active = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 2, + 'captured_at' => now()->subDays(10), + ]); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', [$archived->id, $active->id], 2); + + (new BulkPolicyVersionForceDeleteJob($run->id))->handle($service); + + $run->refresh(); + + expect($run->status)->toBe('completed') + ->and($run->processed_items)->toBe(2) + ->and($run->succeeded)->toBe(1) + ->and($run->skipped)->toBe(1) + ->and($run->failed)->toBe(0); + + expect(PolicyVersion::withTrashed()->whereKey($archived->id)->exists())->toBeFalse(); + expect($active->refresh()->trashed())->toBeFalse(); + + $reasons = collect($run->failures)->pluck('reason')->all(); + expect($reasons)->toContain('Not archived'); +}); + +test('job aborts when the failure threshold is exceeded', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', [999999], 1); + + (new BulkPolicyVersionForceDeleteJob($run->id))->handle($service); + + $run->refresh(); + + expect($run->status)->toBe('aborted') + ->and($run->failed)->toBe(1) + ->and($run->processed_items)->toBe(1); +}); diff --git a/tests/Unit/BulkPolicyVersionPruneJobTest.php b/tests/Unit/BulkPolicyVersionPruneJobTest.php new file mode 100644 index 0000000..5151902 --- /dev/null +++ b/tests/Unit/BulkPolicyVersionPruneJobTest.php @@ -0,0 +1,120 @@ +create(); + $user = User::factory()->create(); + + $policyA = Policy::factory()->create(['tenant_id' => $tenant->id]); + $eligible = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policyA->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(120), + ]); + $current = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policyA->id, + 'version_number' => 2, + 'captured_at' => now()->subDays(120), + ]); + + $policyB = Policy::factory()->create(['tenant_id' => $tenant->id]); + $tooRecent = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policyB->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(10), + ]); + PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policyB->id, + 'version_number' => 2, + 'captured_at' => now()->subDays(10), + ]); + + $service = app(BulkOperationService::class); + $run = $service->createRun( + $tenant, + $user, + 'policy_version', + 'prune', + [$eligible->id, $current->id, $tooRecent->id], + 3 + ); + + (new BulkPolicyVersionPruneJob($run->id, 90))->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->processed_items)->toBe(3) + ->and($run->succeeded)->toBe(1) + ->and($run->skipped)->toBe(2) + ->and($run->failed)->toBe(0); + + expect($eligible->refresh()->trashed())->toBeTrue(); + expect($current->refresh()->trashed())->toBeFalse(); + expect($tooRecent->refresh()->trashed())->toBeFalse(); + + expect($run->failures)->toBeArray(); + expect(collect($run->failures)->pluck('type')->all())->toContain('skipped'); + + $reasons = collect($run->failures)->pluck('reason')->all(); + expect($reasons)->toContain('Current version') + ->and($reasons)->toContain('Too recent'); +}); + +test('job records failure when version is missing', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy_version', 'prune', [999999], 1); + + (new BulkPolicyVersionPruneJob($run->id, 90))->handle($service); + + $run->refresh(); + expect($run->status)->toBe('aborted') + ->and($run->failed)->toBe(1) + ->and($run->processed_items)->toBe(1); +}); + +test('job skips already archived versions instead of treating them as missing', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); + $archived = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(120), + ]); + $archived->delete(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy_version', 'prune', [$archived->id], 1); + + (new BulkPolicyVersionPruneJob($run->id, 90))->handle($service); + + $run->refresh(); + + expect($run->status)->toBe('completed') + ->and($run->processed_items)->toBe(1) + ->and($run->succeeded)->toBe(0) + ->and($run->skipped)->toBe(1) + ->and($run->failed)->toBe(0); + + $reasons = collect($run->failures)->pluck('reason')->all(); + expect($reasons)->toContain('Already archived'); +}); diff --git a/tests/Unit/BulkPolicyVersionRestoreJobTest.php b/tests/Unit/BulkPolicyVersionRestoreJobTest.php new file mode 100644 index 0000000..fc992a6 --- /dev/null +++ b/tests/Unit/BulkPolicyVersionRestoreJobTest.php @@ -0,0 +1,88 @@ +create(['is_current' => true]); + $user = User::factory()->create(); + + $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(120), + ]); + + $version->delete(); + expect($version->trashed())->toBeTrue(); + + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'resource' => 'policy_version', + 'action' => 'restore', + 'status' => 'pending', + 'total_items' => 1, + 'item_ids' => [$version->id], + 'failures' => [], + ]); + + (new BulkPolicyVersionRestoreJob($run->id))->handle(app(BulkOperationService::class)); + + $version->refresh(); + expect($version->trashed())->toBeFalse(); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->succeeded)->toBe(1) + ->and($run->skipped)->toBe(0) + ->and($run->failed)->toBe(0); +}); + +test('bulk policy version restore skips active versions', function () { + $tenant = Tenant::factory()->create(['is_current' => true]); + $user = User::factory()->create(); + + $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(120), + ]); + + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'resource' => 'policy_version', + 'action' => 'restore', + 'status' => 'pending', + 'total_items' => 1, + 'item_ids' => [$version->id], + 'failures' => [], + ]); + + (new BulkPolicyVersionRestoreJob($run->id))->handle(app(BulkOperationService::class)); + + $version->refresh(); + expect($version->trashed())->toBeFalse(); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->succeeded)->toBe(0) + ->and($run->skipped)->toBe(1) + ->and($run->failed)->toBe(0); + + expect(collect($run->failures)->pluck('reason')->all())->toContain('Not archived'); +}); diff --git a/tests/Unit/CircuitBreakerTest.php b/tests/Unit/CircuitBreakerTest.php new file mode 100644 index 0000000..b1bb4ef --- /dev/null +++ b/tests/Unit/CircuitBreakerTest.php @@ -0,0 +1,47 @@ +create(); + $user = User::factory()->create(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'delete', [100001, 100002, 100003, 100004, 100005, 100006, 100007, 100008, 100009, 100010], 10); + + (new BulkPolicyDeleteJob($run->id))->handle($service); + + $run->refresh(); + expect($run->status)->toBe('aborted') + ->and($run->failed)->toBe(6) + ->and($run->processed_items)->toBe(6); +}); + +test('bulk export aborts when more than half of items fail', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'export', [200001, 200002, 200003, 200004, 200005, 200006, 200007, 200008, 200009, 200010], 10); + + (new BulkPolicyExportJob($run->id, 'Circuit Breaker Backup'))->handle($service); + + $run->refresh(); + expect($run->status)->toBe('aborted') + ->and($run->failed)->toBe(6) + ->and($run->processed_items)->toBe(6); + + $this->assertDatabaseHas('backup_sets', [ + 'tenant_id' => $tenant->id, + 'name' => 'Circuit Breaker Backup', + 'status' => 'failed', + ]); +}); diff --git a/tests/Unit/PolicyVersionEligibilityTest.php b/tests/Unit/PolicyVersionEligibilityTest.php new file mode 100644 index 0000000..daac299 --- /dev/null +++ b/tests/Unit/PolicyVersionEligibilityTest.php @@ -0,0 +1,84 @@ +create(); + $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); + + $oldNonCurrent = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(120), + ]); + + PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 2, + 'captured_at' => now()->subDays(10), + ]); + + $eligibleIds = PolicyVersion::query() + ->where('tenant_id', $tenant->id) + ->pruneEligible(90) + ->pluck('id') + ->all(); + + expect($eligibleIds)->toBe([$oldNonCurrent->id]); +}); + +test('pruneEligible excludes current even when old', function () { + $tenant = Tenant::factory()->create(); + $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); + + PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(120), + ]); + + $eligibleCount = PolicyVersion::query() + ->where('tenant_id', $tenant->id) + ->pruneEligible(90) + ->count(); + + expect($eligibleCount)->toBe(0); +}); + +test('pruneEligible excludes archived versions', function () { + $tenant = Tenant::factory()->create(); + $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); + + $archived = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(120), + ]); + + PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 2, + 'captured_at' => now()->subDays(120), + ]); + + $archived->delete(); + + $eligibleIds = PolicyVersion::query() + ->where('tenant_id', $tenant->id) + ->pruneEligible(90) + ->pluck('id') + ->all(); + + expect($eligibleIds)->not->toContain($archived->id); +}); -- 2.45.2 From 623171c9046443f2138ad00cbbc4c57ead33d0b8 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 01:37:05 +0100 Subject: [PATCH 08/21] spec: align 005 bulk ops docs --- specs/005-bulk-operations/plan.md | 2 +- specs/005-bulk-operations/spec.md | 20 ++++++++++++------- specs/005-bulk-operations/tasks.md | 32 +++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/specs/005-bulk-operations/plan.md b/specs/005-bulk-operations/plan.md index 0a79cf8..2455f5b 100644 --- a/specs/005-bulk-operations/plan.md +++ b/specs/005-bulk-operations/plan.md @@ -88,7 +88,7 @@ ### Documentation (this feature) ├── research.md # Phase 0 output (see below) ├── data-model.md # Phase 1 output (see below) ├── quickstart.md # Phase 1 output (see below) -└── tasks.md # Phase 2 output (/speckit.tasks command - NOT YET CREATED) +└── tasks.md # Phase 2 output (/speckit.tasks command - generated and tracked here) ``` ### Source Code (repository root) diff --git a/specs/005-bulk-operations/spec.md b/specs/005-bulk-operations/spec.md index 1c3cc37..7d578e0 100644 --- a/specs/005-bulk-operations/spec.md +++ b/specs/005-bulk-operations/spec.md @@ -90,16 +90,16 @@ ### User Story 3 - Bulk Delete Policy Versions (Priority: P2) **Acceptance Criteria:** 1. **Given** I select 30 policy versions older than 90 days, - **When** I click "Delete", - **Then** confirmation dialog: "Delete 30 policy versions? This is permanent and cannot be undone." + **When** I click "Prune (Archive)", + **Then** confirmation dialog: "Archive 30 policy versions? Archived versions can be restored until force-deleted." 2. **Given** I confirm, **When** the operation completes, **Then**: - System checks each version: is_current=false + not referenced + age >90 days - - Eligible versions are hard-deleted + - Eligible versions are archived (soft-deleted) - Ineligible versions are skipped with reason (e.g., "Referenced by Backup Set ID 5") - - Success notification: "Deleted 28 policy versions (2 skipped)" + - Success notification: "Archived 28 policy versions (2 skipped)" - Audit log: `policy_versions.bulk_pruned` with version IDs + skip reasons 3. **Given** I lack `policy_versions.prune` permission, @@ -299,15 +299,21 @@ ### Policy Versions Resource | Action | Priority | Destructive | Threshold for Queue | Type-to-Confirm | |--------|----------|-------------|---------------------|-----------------| -| Delete | P2 | Yes | ≥20 | ≥20 | +| Prune (archive) | P2 | Yes (local) | ≥20 | ≥20 | +| Restore (unarchive) | P2 | No | ≥20 | No | +| Force delete (permanent) | P2 | Yes (permanent) | ≥20 | ≥20 | | Export to Backup | P3 | No | ≥20 | No | -**FR-005.20**: Bulk Delete for Policy Versions MUST: +**FR-005.20**: Bulk Prune (Archive) for Policy Versions MUST: - Check eligibility: `is_current = false` AND `created_at < NOW() - 90 days` AND NOT referenced - Referenced = exists in `backup_items.policy_version_id` OR `restore_runs.metadata` OR critical audit logs -- Hard-delete eligible versions +- Archive (soft-delete) eligible versions - Skip ineligible with reason: "Referenced", "Too recent", "Current version" +**FR-005.20a**: System MUST provide bulk restore for archived Policy Versions (restore only if archived). + +**FR-005.20b**: System MUST provide bulk force delete for archived Policy Versions (permanent). + **FR-005.21**: System MUST require `policy_versions.prune` permission (separate from `policy_versions.delete`). ### Backup Sets Resource diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md index 9e5e7b8..94bde21 100644 --- a/specs/005-bulk-operations/tasks.md +++ b/specs/005-bulk-operations/tasks.md @@ -98,6 +98,25 @@ ### Implementation for User Story 2 --- +## Phase 4b: Requirement - Bulk Sync Policies (FR-005.19) + +**Goal**: Enable admins to queue a sync (re-fetch) for selected policies in one action. + +**Independent Test**: Select 25 policies → bulk sync → verify job(s) queued and progress/finish notifications. + +### Tests for Bulk Sync Policies + +- [ ] T035a [P] Write feature test for bulk sync dispatch in tests/Feature/BulkSyncPoliciesTest.php +- [ ] T035b [P] Write permission test for bulk sync action in tests/Unit/BulkActionPermissionTest.php + +### Implementation for Bulk Sync Policies + +- [ ] T035c Add bulk sync action to PolicyResource in app/Filament/Resources/PolicyResource.php +- [ ] T035d Dispatch SyncPoliciesJob for selected policies (choose per-ID or batched IDs) in app/Jobs/SyncPoliciesJob.php (or a new dedicated bulk sync job) +- [ ] T035e Manual QA: bulk sync 25 policies (verify queued processing + notifications) + +**Checkpoint**: Bulk sync action queues work and respects permissions + ## Phase 5: User Story 5 - Type-to-Confirm (Priority: P1) **Goal**: Require typing "DELETE" for destructive operations with ≥20 items @@ -147,6 +166,10 @@ ### Implementation for User Story 6 - [ ] T053 [US6] Test circuit breaker with mock failures (manual QA) - [ ] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php` +- [ ] T054a [US6] Define/verify polling interval meets NFR-005.3 (≤10s updates) and document where configured +- [ ] T054b [US6] Manual QA: force a catastrophic job failure and verify FR-005.13 notification behavior +- [ ] T054c [US6] Manual QA: verify UI remains responsive during a 100-item queued run (NFR-005.4) + **Checkpoint**: Progress tracking working, polling updates UI, circuit breaker aborts high-failure jobs --- @@ -155,7 +178,7 @@ ## Phase 7: User Story 3 - Bulk Delete Policy Versions (Priority: P2) **Goal**: Enable admins to prune old policy versions that are NOT referenced and meet retention threshold (>90 days) -**Independent Test**: Select 30 old versions → bulk prune → verify eligible deleted, ineligible skipped with reasons +**Independent Test**: Select 30 old versions → bulk prune → verify eligible archived, ineligible skipped with reasons ### Tests for User Story 3 @@ -234,6 +257,13 @@ ### Implementation for Additional Resource - [ ] T084 Test delete with 15 backup sets (manual QA) - [ ] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php` +### Additional: Bulk Archive Backup Sets (FR-005.23) + +- [ ] T085a Verify BackupSet has `archived_at` column; add migration if missing in database/migrations/ +- [ ] T085b Add bulk archive action to BackupSetResource in app/Filament/Resources/BackupSetResource.php +- [ ] T085c [P] Write tests for bulk archive in tests/Feature/BulkArchiveBackupSetsTest.php (and unit test if a job/service is introduced) +- [ ] T085d Manual QA: archive 15 backup sets and verify listing/filter behavior + **Checkpoint**: Backup sets bulk delete working, cascade-delete verified --- -- 2.45.2 From 018ab4e6e64a6e2be6d9bbe441c554c70793e20c Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 01:44:21 +0100 Subject: [PATCH 09/21] spec: mark P5/P6 tests complete --- specs/005-bulk-operations/tasks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md index 94bde21..81c2d00 100644 --- a/specs/005-bulk-operations/tasks.md +++ b/specs/005-bulk-operations/tasks.md @@ -136,7 +136,7 @@ ### Validation for User Story 5 - [ ] T039 [US5] Manual test: Bulk delete 10 policies → confirm without typing (should work) - [ ] T040 [US5] Manual test: Bulk delete 25 policies → type "delete" → button stays disabled - [ ] T041 [US5] Manual test: Bulk delete 25 policies → type "DELETE" → button enables, operation proceeds -- [ ] T042 [US5] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkTypeToConfirmTest.php` +- [x] T042 [US5] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkTypeToConfirmTest.php` **Checkpoint**: Type-to-confirm working correctly for all thresholds @@ -164,7 +164,7 @@ ### Implementation for User Story 6 - [x] T051 [US6] Add progress polling to Filament notifications or sidebar widget - [ ] T052 [US6] Test progress with 100 policies (manual QA, observe updates) - [ ] T053 [US6] Test circuit breaker with mock failures (manual QA) -- [ ] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php` +- [x] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php` - [ ] T054a [US6] Define/verify polling interval meets NFR-005.3 (≤10s updates) and document where configured - [ ] T054b [US6] Manual QA: force a catastrophic job failure and verify FR-005.13 notification behavior -- 2.45.2 From a05aefec9a8757f956165c105a3a76ae78bcfebb Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 01:59:03 +0100 Subject: [PATCH 10/21] feat: bulk sync selected policies --- app/Filament/Resources/PolicyResource.php | 39 ++++++ app/Jobs/BulkPolicySyncJob.php | 147 ++++++++++++++++++++++ app/Services/Intune/PolicySyncService.php | 65 ++++++++++ specs/005-bulk-operations/tasks.md | 8 +- tests/Feature/BulkSyncPoliciesTest.php | 84 +++++++++++++ tests/Unit/BulkActionPermissionTest.php | 20 ++- 6 files changed, 353 insertions(+), 10 deletions(-) create mode 100644 app/Jobs/BulkPolicySyncJob.php create mode 100644 tests/Feature/BulkSyncPoliciesTest.php diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index cf762de..d552e6a 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -6,6 +6,7 @@ use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager; use App\Jobs\BulkPolicyDeleteJob; use App\Jobs\BulkPolicyExportJob; +use App\Jobs\BulkPolicySyncJob; use App\Jobs\BulkPolicyUnignoreJob; use App\Models\Policy; use App\Models\Tenant; @@ -420,6 +421,44 @@ public static function table(Table $table): Table }) ->deselectRecordsAfterCompletion(), + BulkAction::make('bulk_sync') + ->label('Sync Policies') + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $visibilityFilterState = $livewire->getTableFilterState('visibility') ?? []; + $value = $visibilityFilterState['value'] ?? null; + + return $value === 'ignored'; + }) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'sync', $ids, $count); + + if ($count >= 20) { + Notification::make() + ->title('Bulk sync started') + ->body("Syncing {$count} policies in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkPolicySyncJob::dispatch($run->id); + } else { + BulkPolicySyncJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + BulkAction::make('bulk_export') ->label('Export to Backup') ->icon('heroicon-o-archive-box-arrow-down') diff --git a/app/Jobs/BulkPolicySyncJob.php b/app/Jobs/BulkPolicySyncJob.php new file mode 100644 index 0000000..559eaf5 --- /dev/null +++ b/app/Jobs/BulkPolicySyncJob.php @@ -0,0 +1,147 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + try { + $chunkSize = 10; + $itemCount = 0; + + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $policyId) { + $itemCount++; + + try { + $policy = Policy::query() + ->whereKey($policyId) + ->where('tenant_id', $run->tenant_id) + ->first(); + + if (! $policy) { + $service->recordFailure($run, (string) $policyId, 'Policy not found'); + + if ($run->failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Sync Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if ($policy->ignored_at) { + $service->recordSkippedWithReason($run, (string) $policyId, 'Policy is ignored locally'); + + continue; + } + + $syncService->syncPolicy($run->tenant, $policy); + + $service->recordSuccess($run); + } catch (Throwable $e) { + $service->recordFailure($run, (string) $policyId, $e->getMessage()); + + if ($run->failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Sync Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if ($run->user) { + $message = "Synced {$run->succeeded} policies"; + + if ($run->skipped > 0) { + $message .= " ({$run->skipped} skipped)"; + } + + if ($run->failed > 0) { + $message .= " ({$run->failed} failed)"; + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Sync Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } + } catch (Throwable $e) { + $service->fail($run, $e->getMessage()); + + $run->refresh(); + $run->load('user'); + + if ($run->user) { + Notification::make() + ->title('Bulk Sync Failed') + ->body($e->getMessage()) + ->icon('heroicon-o-x-circle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + throw $e; + } + } +} diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index a920120..6ed859c 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -8,6 +8,7 @@ use App\Services\Graph\GraphErrorMapper; use App\Services\Graph\GraphLogger; use Illuminate\Support\Arr; +use RuntimeException; use Throwable; class PolicySyncService @@ -108,4 +109,68 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr return $synced; } + + /** + * Re-fetch a single policy from Graph and update local metadata. + */ + public function syncPolicy(Tenant $tenant, Policy $policy): void + { + if (! $tenant->isActive()) { + throw new RuntimeException('Tenant is archived or inactive.'); + } + + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + + $this->graphLogger->logRequest('get_policy', [ + 'tenant' => $tenantIdentifier, + 'policy_type' => $policy->policy_type, + 'policy_id' => $policy->external_id, + 'platform' => $policy->platform, + ]); + + try { + $response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + 'platform' => $policy->platform, + ]); + } catch (Throwable $throwable) { + throw GraphErrorMapper::fromThrowable($throwable, [ + 'policy_type' => $policy->policy_type, + 'policy_id' => $policy->external_id, + 'tenant_id' => $tenant->id, + 'tenant_identifier' => $tenantIdentifier, + ]); + } + + $this->graphLogger->logResponse('get_policy', $response, [ + 'tenant_id' => $tenant->id, + 'tenant' => $tenantIdentifier, + 'policy_type' => $policy->policy_type, + 'policy_id' => $policy->external_id, + ]); + + if ($response->failed()) { + $message = $response->errors[0]['message'] ?? $response->data['error']['message'] ?? 'Graph request failed.'; + + throw new RuntimeException($message); + } + + $payload = $response->data['payload'] ?? $response->data; + + if (! is_array($payload)) { + throw new RuntimeException('Invalid Graph response payload.'); + } + + $displayName = $payload['displayName'] ?? $payload['name'] ?? $policy->display_name; + $platform = $payload['platform'] ?? $policy->platform; + + $policy->forceFill([ + 'display_name' => $displayName, + 'platform' => $platform, + 'last_synced_at' => now(), + 'metadata' => Arr::except($payload, ['id', 'external_id', 'displayName', 'name', 'platform']), + ])->save(); + } } diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md index 81c2d00..7e6eaf7 100644 --- a/specs/005-bulk-operations/tasks.md +++ b/specs/005-bulk-operations/tasks.md @@ -106,13 +106,13 @@ ## Phase 4b: Requirement - Bulk Sync Policies (FR-005.19) ### Tests for Bulk Sync Policies -- [ ] T035a [P] Write feature test for bulk sync dispatch in tests/Feature/BulkSyncPoliciesTest.php -- [ ] T035b [P] Write permission test for bulk sync action in tests/Unit/BulkActionPermissionTest.php +- [x] T035a [P] Write feature test for bulk sync dispatch in tests/Feature/BulkSyncPoliciesTest.php +- [x] T035b [P] Write permission test for bulk sync action in tests/Unit/BulkActionPermissionTest.php ### Implementation for Bulk Sync Policies -- [ ] T035c Add bulk sync action to PolicyResource in app/Filament/Resources/PolicyResource.php -- [ ] T035d Dispatch SyncPoliciesJob for selected policies (choose per-ID or batched IDs) in app/Jobs/SyncPoliciesJob.php (or a new dedicated bulk sync job) +- [x] T035c Add bulk sync action to PolicyResource in app/Filament/Resources/PolicyResource.php +- [x] T035d Dispatch SyncPoliciesJob for selected policies (choose per-ID or batched IDs) in app/Jobs/SyncPoliciesJob.php (or a new dedicated bulk sync job) - [ ] T035e Manual QA: bulk sync 25 policies (verify queued processing + notifications) **Checkpoint**: Bulk sync action queues work and respects permissions diff --git a/tests/Feature/BulkSyncPoliciesTest.php b/tests/Feature/BulkSyncPoliciesTest.php new file mode 100644 index 0000000..ad50805 --- /dev/null +++ b/tests/Feature/BulkSyncPoliciesTest.php @@ -0,0 +1,84 @@ +create(); + $user = User::factory()->create(); + + $policies = Policy::factory() + ->count(3) + ->create([ + 'tenant_id' => $tenant->id, + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10AndLater', + 'last_synced_at' => null, + ]); + + app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, [ + 'payload' => [ + 'id' => $policyId, + 'displayName' => "Synced {$policyId}", + 'platform' => $options['platform'] ?? null, + 'example' => 'value', + ], + ]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + Livewire::actingAs($user) + ->test(PolicyResource\Pages\ListPolicies::class) + ->callTableBulkAction('bulk_sync', $policies) + ->assertHasNoTableBulkActionErrors(); + + $policies->each(function (Policy $policy) { + $policy->refresh(); + + expect($policy->last_synced_at)->not->toBeNull(); + expect($policy->display_name)->toBe("Synced {$policy->external_id}"); + expect($policy->metadata)->toMatchArray([ + 'example' => 'value', + ]); + }); + + expect(AuditLog::where('action', 'bulk.policy.sync.completed')->exists())->toBeTrue(); +}); diff --git a/tests/Unit/BulkActionPermissionTest.php b/tests/Unit/BulkActionPermissionTest.php index 60d3c04..e6e8986 100644 --- a/tests/Unit/BulkActionPermissionTest.php +++ b/tests/Unit/BulkActionPermissionTest.php @@ -1,14 +1,22 @@ create(); + $user = User::factory()->create(); + $policies = Policy::factory()->count(2)->create(['tenant_id' => $tenant->id]); - expect(true)->toBeTrue(); + Livewire::actingAs($user) + ->test(PolicyResource\Pages\ListPolicies::class) + ->callTableBulkAction('bulk_sync', $policies) + ->assertHasNoTableBulkActionErrors(); }); -- 2.45.2 From e603a1245eef23e6657f908b3f2101b7726bfcfd Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 02:59:31 +0100 Subject: [PATCH 11/21] feat: restore runs bulk archive/restore/force delete - Add bulk restore + archived-only force delete actions - Add jobs + tests for bulk restore/force delete - Treat restore_run status 'partial' as deletable for hygiene - Update feature tasks checklist --- app/Filament/Resources/RestoreRunResource.php | 178 +++++++++++++++++- app/Jobs/BulkRestoreRunDeleteJob.php | 177 +++++++++++++++++ app/Jobs/BulkRestoreRunForceDeleteJob.php | 150 +++++++++++++++ app/Jobs/BulkRestoreRunRestoreJob.php | 150 +++++++++++++++ app/Models/RestoreRun.php | 7 +- specs/005-bulk-operations/tasks.md | 28 ++- tests/Feature/BulkDeleteMixedStatusTest.php | 61 ++++++ tests/Feature/BulkDeleteRestoreRunsTest.php | 50 +++++ .../BulkForceDeleteRestoreRunsTest.php | 57 ++++++ tests/Feature/BulkRestoreRestoreRunsTest.php | 57 ++++++ tests/Feature/RestoreRunArchiveGuardTest.php | 37 ++++ tests/Unit/BulkRestoreRunDeleteJobTest.php | 94 +++++++++ tests/Unit/BulkRestoreRunRestoreJobTest.php | 102 ++++++++++ tests/Unit/RestoreRunDeletableTest.php | 56 ++++++ 14 files changed, 1191 insertions(+), 13 deletions(-) create mode 100644 app/Jobs/BulkRestoreRunDeleteJob.php create mode 100644 app/Jobs/BulkRestoreRunForceDeleteJob.php create mode 100644 app/Jobs/BulkRestoreRunRestoreJob.php create mode 100644 tests/Feature/BulkDeleteMixedStatusTest.php create mode 100644 tests/Feature/BulkDeleteRestoreRunsTest.php create mode 100644 tests/Feature/BulkForceDeleteRestoreRunsTest.php create mode 100644 tests/Feature/BulkRestoreRestoreRunsTest.php create mode 100644 tests/Feature/RestoreRunArchiveGuardTest.php create mode 100644 tests/Unit/BulkRestoreRunDeleteJobTest.php create mode 100644 tests/Unit/BulkRestoreRunRestoreJobTest.php create mode 100644 tests/Unit/RestoreRunDeletableTest.php diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index bad0ae9..4174710 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -3,14 +3,20 @@ namespace App\Filament\Resources; use App\Filament\Resources\RestoreRunResource\Pages; +use App\Jobs\BulkRestoreRunDeleteJob; +use App\Jobs\BulkRestoreRunForceDeleteJob; +use App\Jobs\BulkRestoreRunRestoreJob; use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\RestoreRun; use App\Models\Tenant; +use App\Services\BulkOperationService; use App\Services\Intune\RestoreService; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; +use Filament\Actions\BulkAction; +use Filament\Actions\BulkActionGroup; use Filament\Forms; use Filament\Infolists; use Filament\Notifications\Notification; @@ -18,7 +24,10 @@ use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; use Filament\Tables; +use Filament\Tables\Contracts\HasTable; +use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Collection; use UnitEnum; class RestoreRunResource extends Resource @@ -105,7 +114,11 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('requested_by')->label('Requested by'), ]) ->filters([ - Tables\Filters\TrashedFilter::make(), + TrashedFilter::make() + ->label('Archived') + ->placeholder('Active') + ->trueLabel('All') + ->falseLabel('Archived'), ]) ->actions([ Actions\ViewAction::make(), @@ -117,6 +130,16 @@ public static function table(Table $table): Table ->requiresConfirmation() ->visible(fn (RestoreRun $record) => ! $record->trashed()) ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + if (! $record->isDeletable()) { + Notification::make() + ->title('Restore run cannot be archived') + ->body("Not deletable (status: {$record->status})") + ->warning() + ->send(); + + return; + } + $record->delete(); if ($record->tenant) { @@ -162,7 +185,158 @@ public static function table(Table $table): Table }), ])->icon('heroicon-o-ellipsis-vertical'), ]) - ->bulkActions([]); + ->bulkActions([ + BulkActionGroup::make([ + BulkAction::make('bulk_delete') + ->label('Archive Restore Runs') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return $isOnlyTrashed; + }) + ->modalDescription('This archives restore runs (soft delete). Already archived runs will be skipped.') + ->form(function (Collection $records) { + if ($records->count() >= 20) { + return [ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]; + } + + return []; + }) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'restore_run', 'delete', $ids, $count); + + if ($count >= 20) { + Notification::make() + ->title('Bulk delete started') + ->body("Deleting {$count} restore runs in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkRestoreRunDeleteJob::dispatch($run->id); + } else { + BulkRestoreRunDeleteJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + + BulkAction::make('bulk_restore') + ->label('Restore Restore Runs') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Restore {$records->count()} restore runs?") + ->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.') + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'restore_run', 'restore', $ids, $count); + + if ($count >= 20) { + Notification::make() + ->title('Bulk restore started') + ->body("Restoring {$count} restore runs in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkRestoreRunRestoreJob::dispatch($run->id); + } else { + BulkRestoreRunRestoreJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + + BulkAction::make('bulk_force_delete') + ->label('Force Delete Restore Runs') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} restore runs?") + ->modalDescription('This is permanent. Only archived restore runs will be permanently deleted; active runs will be skipped.') + ->form([ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'restore_run', 'force_delete', $ids, $count); + + if ($count >= 20) { + Notification::make() + ->title('Bulk force delete started') + ->body("Force deleting {$count} restore runs in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkRestoreRunForceDeleteJob::dispatch($run->id); + } else { + BulkRestoreRunForceDeleteJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + ]), + ]); } public static function infolist(Schema $schema): Schema diff --git a/app/Jobs/BulkRestoreRunDeleteJob.php b/app/Jobs/BulkRestoreRunDeleteJob.php new file mode 100644 index 0000000..e5e3ec3 --- /dev/null +++ b/app/Jobs/BulkRestoreRunDeleteJob.php @@ -0,0 +1,177 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + try { + $itemCount = 0; + $succeeded = 0; + $failed = 0; + $skipped = 0; + $skipReasons = []; + + $chunkSize = 10; + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $restoreRunId) { + $itemCount++; + + try { + /** @var RestoreRun|null $restoreRun */ + $restoreRun = RestoreRun::withTrashed() + ->where('tenant_id', $run->tenant_id) + ->whereKey($restoreRunId) + ->first(); + + if (! $restoreRun) { + $service->recordFailure($run, (string) $restoreRunId, 'Restore run not found'); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Delete Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if ($restoreRun->trashed()) { + $service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Already archived'); + $skipped++; + $skipReasons['Already archived'] = ($skipReasons['Already archived'] ?? 0) + 1; + + continue; + } + + if (! $restoreRun->isDeletable()) { + $reason = "Not deletable (status: {$restoreRun->status})"; + + $service->recordSkippedWithReason($run, (string) $restoreRun->id, $reason); + $skipped++; + $skipReasons[$reason] = ($skipReasons[$reason] ?? 0) + 1; + + continue; + } + + $restoreRun->delete(); + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $restoreRunId, $e->getMessage()); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Delete Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if ($run->user) { + $message = "Deleted {$succeeded} restore runs"; + if ($skipped > 0) { + $message .= " ({$skipped} skipped)"; + } + if ($failed > 0) { + $message .= " ({$failed} failed)"; + } + + if (! empty($skipReasons)) { + $summary = collect($skipReasons) + ->sortDesc() + ->map(fn (int $count, string $reason) => "{$reason} ({$count})") + ->take(3) + ->implode(', '); + + if ($summary !== '') { + $message .= " Skip reasons: {$summary}."; + } + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Delete Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } + } catch (Throwable $e) { + $service->fail($run, $e->getMessage()); + + $run->refresh(); + $run->load('user'); + + if ($run->user) { + Notification::make() + ->title('Bulk Delete Failed') + ->body($e->getMessage()) + ->icon('heroicon-o-x-circle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + throw $e; + } + } +} diff --git a/app/Jobs/BulkRestoreRunForceDeleteJob.php b/app/Jobs/BulkRestoreRunForceDeleteJob.php new file mode 100644 index 0000000..d96d5a0 --- /dev/null +++ b/app/Jobs/BulkRestoreRunForceDeleteJob.php @@ -0,0 +1,150 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + $itemCount = 0; + $succeeded = 0; + $failed = 0; + $skipped = 0; + $skipReasons = []; + + $chunkSize = 10; + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $restoreRunId) { + $itemCount++; + + try { + /** @var RestoreRun|null $restoreRun */ + $restoreRun = RestoreRun::withTrashed() + ->where('tenant_id', $run->tenant_id) + ->whereKey($restoreRunId) + ->first(); + + if (! $restoreRun) { + $service->recordFailure($run, (string) $restoreRunId, 'Restore run not found'); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Force Delete Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if (! $restoreRun->trashed()) { + $service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Not archived'); + $skipped++; + $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; + + continue; + } + + $restoreRun->forceDelete(); + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $restoreRunId, $e->getMessage()); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Force Delete Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if (! $run->user) { + return; + } + + $message = "Force deleted {$succeeded} restore runs"; + if ($skipped > 0) { + $message .= " ({$skipped} skipped)"; + } + if ($failed > 0) { + $message .= " ({$failed} failed)"; + } + + if (! empty($skipReasons)) { + $summary = collect($skipReasons) + ->sortDesc() + ->map(fn (int $count, string $reason) => "{$reason} ({$count})") + ->take(3) + ->implode(', '); + + if ($summary !== '') { + $message .= " Skip reasons: {$summary}."; + } + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Force Delete Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } +} diff --git a/app/Jobs/BulkRestoreRunRestoreJob.php b/app/Jobs/BulkRestoreRunRestoreJob.php new file mode 100644 index 0000000..8bf4229 --- /dev/null +++ b/app/Jobs/BulkRestoreRunRestoreJob.php @@ -0,0 +1,150 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + $itemCount = 0; + $succeeded = 0; + $failed = 0; + $skipped = 0; + $skipReasons = []; + + $chunkSize = 10; + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $restoreRunId) { + $itemCount++; + + try { + /** @var RestoreRun|null $restoreRun */ + $restoreRun = RestoreRun::withTrashed() + ->where('tenant_id', $run->tenant_id) + ->whereKey($restoreRunId) + ->first(); + + if (! $restoreRun) { + $service->recordFailure($run, (string) $restoreRunId, 'Restore run not found'); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Restore Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if (! $restoreRun->trashed()) { + $service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Not archived'); + $skipped++; + $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; + + continue; + } + + $restoreRun->restore(); + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $restoreRunId, $e->getMessage()); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Restore Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if (! $run->user) { + return; + } + + $message = "Restored {$succeeded} restore runs"; + if ($skipped > 0) { + $message .= " ({$skipped} skipped)"; + } + if ($failed > 0) { + $message .= " ({$failed} failed)"; + } + + if (! empty($skipReasons)) { + $summary = collect($skipReasons) + ->sortDesc() + ->map(fn (int $count, string $reason) => "{$reason} ({$count})") + ->take(3) + ->implode(', '); + + if ($summary !== '') { + $message .= " Skip reasons: {$summary}."; + } + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Restore Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } +} diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index 244330c..ce7fb50 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -36,6 +36,11 @@ public function backupSet(): BelongsTo public function scopeDeletable($query) { - return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors']); + return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial']); + } + + public function isDeletable(): bool + { + return in_array($this->status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial'], true); } } diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md index 7e6eaf7..e58d7ad 100644 --- a/specs/005-bulk-operations/tasks.md +++ b/specs/005-bulk-operations/tasks.md @@ -218,20 +218,28 @@ ## Phase 8: User Story 4 - Bulk Delete Restore Runs (Priority: P2) ### Tests for User Story 4 -- [ ] T067 [P] [US4] Write unit test for deletable() scope in tests/Unit/RestoreRunDeletableTest.php -- [ ] T068 [P] [US4] Write unit test for BulkRestoreRunDeleteJob in tests/Unit/BulkRestoreRunDeleteJobTest.php -- [ ] T069 [P] [US4] Write feature test for bulk delete restore runs in tests/Feature/BulkDeleteRestoreRunsTest.php -- [ ] T070 [P] [US4] Write test for mixed statuses (skip running) in tests/Feature/BulkDeleteMixedStatusTest.php +- [x] T067 [P] [US4] Write unit test for deletable() scope in tests/Unit/RestoreRunDeletableTest.php +- [x] T068 [P] [US4] Write unit test for BulkRestoreRunDeleteJob in tests/Unit/BulkRestoreRunDeleteJobTest.php +- [x] T069 [P] [US4] Write feature test for bulk delete restore runs in tests/Feature/BulkDeleteRestoreRunsTest.php +- [x] T070 [P] [US4] Write test for mixed statuses (skip running) in tests/Feature/BulkDeleteMixedStatusTest.php ### Implementation for User Story 4 -- [ ] T071 [P] [US4] Create BulkRestoreRunDeleteJob in app/Jobs/BulkRestoreRunDeleteJob.php -- [ ] T072 [US4] Add bulk delete action to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php -- [ ] T073 [US4] Filter by deletable() scope (completed, failed, aborted only) -- [ ] T074 [US4] Skip running restore runs with warning -- [ ] T075 [US4] Add type-to-confirm for ≥20 runs +- [x] T071 [P] [US4] Create BulkRestoreRunDeleteJob in app/Jobs/BulkRestoreRunDeleteJob.php +- [x] T072 [US4] Add bulk delete action to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php +- [x] T073 [US4] Filter by deletable() scope (completed, failed, aborted only) +- [x] T074 [US4] Skip running restore runs with warning +- [x] T075 [US4] Add type-to-confirm for ≥20 runs - [ ] T076 [US4] Test delete with 20 completed + 5 running (manual QA, verify 5 skipped) -- [ ] T077 [US4] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteRestoreRunsTest.php` +- [x] T077 [US4] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteRestoreRunsTest.php` + +- [x] T077a [US4] Add bulk force delete restore runs job in app/Jobs/BulkRestoreRunForceDeleteJob.php +- [x] T077b [US4] Add bulk force delete action (archived-only) to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php +- [x] T077c [US4] Write feature test for bulk force delete in tests/Feature/BulkForceDeleteRestoreRunsTest.php + +- [x] T077d [US4] Add bulk restore restore runs job in app/Jobs/BulkRestoreRunRestoreJob.php +- [x] T077e [US4] Add bulk restore action (archived-only) to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php +- [x] T077f [US4] Write unit+feature tests for bulk restore in tests/Unit/BulkRestoreRunRestoreJobTest.php and tests/Feature/BulkRestoreRestoreRunsTest.php **Checkpoint**: Restore runs bulk delete working, running runs protected, skip warnings shown diff --git a/tests/Feature/BulkDeleteMixedStatusTest.php b/tests/Feature/BulkDeleteMixedStatusTest.php new file mode 100644 index 0000000..be7dbbb --- /dev/null +++ b/tests/Feature/BulkDeleteMixedStatusTest.php @@ -0,0 +1,61 @@ +create(); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $completedRuns = collect(range(1, 3))->map(function () use ($tenant, $backupSet) { + return RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + }); + + $running = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'running', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $records = $completedRuns->concat([$running]); + + Livewire::actingAs($user) + ->test(RestoreRunResource\Pages\ListRestoreRuns::class) + ->callTableBulkAction('bulk_delete', $records) + ->assertHasNoTableBulkActionErrors(); + + $completedRuns->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue()); + expect(RestoreRun::withTrashed()->find($running->id)?->trashed())->toBeFalse(); + + $bulkRun = BulkOperationRun::query() + ->where('resource', 'restore_run') + ->where('action', 'delete') + ->latest('id') + ->first(); + + expect($bulkRun)->not->toBeNull(); + expect($bulkRun->skipped)->toBeGreaterThanOrEqual(1); +}); diff --git a/tests/Feature/BulkDeleteRestoreRunsTest.php b/tests/Feature/BulkDeleteRestoreRunsTest.php new file mode 100644 index 0000000..1711219 --- /dev/null +++ b/tests/Feature/BulkDeleteRestoreRunsTest.php @@ -0,0 +1,50 @@ +create(); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $runs = collect(range(1, 5))->map(function () use ($tenant, $backupSet) { + return RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + }); + + Livewire::actingAs($user) + ->test(RestoreRunResource\Pages\ListRestoreRuns::class) + ->callTableBulkAction('bulk_delete', $runs) + ->assertHasNoTableBulkActionErrors(); + + $runs->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue()); + + $bulkRun = BulkOperationRun::query() + ->where('resource', 'restore_run') + ->where('action', 'delete') + ->latest('id') + ->first(); + + expect($bulkRun)->not->toBeNull(); + expect($bulkRun->status)->toBe('completed'); +}); diff --git a/tests/Feature/BulkForceDeleteRestoreRunsTest.php b/tests/Feature/BulkForceDeleteRestoreRunsTest.php new file mode 100644 index 0000000..3e3a583 --- /dev/null +++ b/tests/Feature/BulkForceDeleteRestoreRunsTest.php @@ -0,0 +1,57 @@ +create(); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $runs = collect(range(1, 3))->map(function () use ($tenant, $backupSet) { + $run = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $run->delete(); + + return $run; + }); + + Livewire::actingAs($user) + ->test(RestoreRunResource\Pages\ListRestoreRuns::class) + ->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false) + ->callTableBulkAction('bulk_force_delete', $runs, data: [ + 'confirmation' => 'DELETE', + ]) + ->assertHasNoTableBulkActionErrors(); + + $runs->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id))->toBeNull()); + + $bulkRun = BulkOperationRun::query() + ->where('resource', 'restore_run') + ->where('action', 'force_delete') + ->latest('id') + ->first(); + + expect($bulkRun)->not->toBeNull(); + expect($bulkRun->status)->toBe('completed'); +}); diff --git a/tests/Feature/BulkRestoreRestoreRunsTest.php b/tests/Feature/BulkRestoreRestoreRunsTest.php new file mode 100644 index 0000000..58d696f --- /dev/null +++ b/tests/Feature/BulkRestoreRestoreRunsTest.php @@ -0,0 +1,57 @@ +create(); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $run = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $run->delete(); + expect($run->trashed())->toBeTrue(); + + Livewire::actingAs($user) + ->test(RestoreRunResource\Pages\ListRestoreRuns::class) + ->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false) + ->callTableBulkAction('bulk_restore', collect([$run])) + ->assertHasNoTableBulkActionErrors(); + + $bulkRun = BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('resource', 'restore_run') + ->where('action', 'restore') + ->latest('id') + ->first(); + + expect($bulkRun)->not->toBeNull(); + expect($bulkRun->succeeded)->toBe(1) + ->and($bulkRun->skipped)->toBe(0) + ->and($bulkRun->failed)->toBe(0); + + $run->refresh(); + expect($run->trashed())->toBeFalse(); +}); diff --git a/tests/Feature/RestoreRunArchiveGuardTest.php b/tests/Feature/RestoreRunArchiveGuardTest.php new file mode 100644 index 0000000..50c7c57 --- /dev/null +++ b/tests/Feature/RestoreRunArchiveGuardTest.php @@ -0,0 +1,37 @@ +create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Set RR', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $running = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'running', + 'is_dry_run' => true, + ]); + + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(ListRestoreRuns::class) + ->callTableAction('archive', $running); + + expect(RestoreRun::withTrashed()->find($running->id)?->trashed())->toBeFalse(); +}); diff --git a/tests/Unit/BulkRestoreRunDeleteJobTest.php b/tests/Unit/BulkRestoreRunDeleteJobTest.php new file mode 100644 index 0000000..fc3e6c6 --- /dev/null +++ b/tests/Unit/BulkRestoreRunDeleteJobTest.php @@ -0,0 +1,94 @@ +create(); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $completed = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $failed = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'failed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'restore_run', 'delete', [$completed->id, $failed->id], 2); + + $job = new BulkRestoreRunDeleteJob($run->id); + $job->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->processed_items)->toBe(2) + ->and($run->succeeded)->toBe(2) + ->and($run->failed)->toBe(0) + ->and($run->skipped)->toBe(0); + + expect(RestoreRun::withTrashed()->find($completed->id)?->trashed())->toBeTrue(); + expect(RestoreRun::withTrashed()->find($failed->id)?->trashed())->toBeTrue(); +}); + +test('job skips non-deletable restore runs and records skip reasons', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $running = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'running', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'restore_run', 'delete', [$running->id], 1); + + $job = new BulkRestoreRunDeleteJob($run->id); + $job->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->processed_items)->toBe(1) + ->and($run->succeeded)->toBe(0) + ->and($run->failed)->toBe(0) + ->and($run->skipped)->toBe(1); + + expect($run->failures[0]['type'] ?? null)->toBe('skipped'); + expect($run->failures[0]['reason'] ?? '')->toContain('Not deletable'); + + expect(RestoreRun::withTrashed()->find($running->id)?->trashed())->toBeFalse(); +}); diff --git a/tests/Unit/BulkRestoreRunRestoreJobTest.php b/tests/Unit/BulkRestoreRunRestoreJobTest.php new file mode 100644 index 0000000..3e53f64 --- /dev/null +++ b/tests/Unit/BulkRestoreRunRestoreJobTest.php @@ -0,0 +1,102 @@ +create(['is_current' => true]); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $restoreRun->delete(); + expect($restoreRun->trashed())->toBeTrue(); + + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'resource' => 'restore_run', + 'action' => 'restore', + 'status' => 'pending', + 'total_items' => 1, + 'item_ids' => [$restoreRun->id], + 'failures' => [], + ]); + + (new BulkRestoreRunRestoreJob($run->id))->handle(app(BulkOperationService::class)); + + $restoreRun->refresh(); + expect($restoreRun->trashed())->toBeFalse(); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->succeeded)->toBe(1) + ->and($run->skipped)->toBe(0) + ->and($run->failed)->toBe(0); +}); + +test('bulk restore run restore skips active runs', function () { + $tenant = Tenant::factory()->create(['is_current' => true]); + $user = User::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'resource' => 'restore_run', + 'action' => 'restore', + 'status' => 'pending', + 'total_items' => 1, + 'item_ids' => [$restoreRun->id], + 'failures' => [], + ]); + + (new BulkRestoreRunRestoreJob($run->id))->handle(app(BulkOperationService::class)); + + $restoreRun->refresh(); + expect($restoreRun->trashed())->toBeFalse(); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->succeeded)->toBe(0) + ->and($run->skipped)->toBe(1) + ->and($run->failed)->toBe(0); + + expect(collect($run->failures)->pluck('reason')->all())->toContain('Not archived'); +}); diff --git a/tests/Unit/RestoreRunDeletableTest.php b/tests/Unit/RestoreRunDeletableTest.php new file mode 100644 index 0000000..0ea051c --- /dev/null +++ b/tests/Unit/RestoreRunDeletableTest.php @@ -0,0 +1,56 @@ +create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $statuses = [ + 'completed', + 'failed', + 'aborted', + 'completed_with_errors', + 'partial', + 'running', + 'pending', + ]; + + foreach ($statuses as $status) { + RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => $status, + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + } + + $deletableStatuses = RestoreRun::query() + ->deletable() + ->pluck('status') + ->unique() + ->sort() + ->values() + ->all(); + + expect($deletableStatuses)->toBe([ + 'aborted', + 'completed', + 'completed_with_errors', + 'failed', + 'partial', + ]); +}); -- 2.45.2 From 99f2a6309d54e3c894f0ccc0a8ebc2d3f48be5de Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 03:00:11 +0100 Subject: [PATCH 12/21] chore: align UI labels and fix housekeeping - Rename Policies bulk action to 'Ignore Policies' - Align archived filter labels across resources - Fix Housekeeping tests by setting current tenant --- app/Filament/Resources/BackupSetResource.php | 6 +++++- app/Filament/Resources/PolicyResource.php | 2 +- app/Filament/Resources/TenantResource.php | 8 ++++---- tests/Feature/Filament/HousekeepingTest.php | 4 ++++ 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index e889d00..511cda3 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -51,7 +51,11 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('created_at')->dateTime()->since(), ]) ->filters([ - Tables\Filters\TrashedFilter::make(), + Tables\Filters\TrashedFilter::make() + ->label('Archived') + ->placeholder('Active') + ->trueLabel('All') + ->falseLabel('Archived'), ]) ->actions([ Actions\ViewAction::make() diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index d552e6a..2ef039e 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -331,7 +331,7 @@ public static function table(Table $table): Table ->bulkActions([ BulkActionGroup::make([ BulkAction::make('bulk_delete') - ->label('Delete Policies') + ->label('Ignore Policies') ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 567b830..cc845b0 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -97,10 +97,10 @@ public static function table(Table $table): Table ]) ->filters([ Tables\Filters\TrashedFilter::make() - ->label('Archive filter') - ->placeholder('Active only') - ->trueLabel('Active + archived') - ->falseLabel('Archived only') + ->label('Archived') + ->placeholder('Active') + ->trueLabel('All') + ->falseLabel('Archived') ->default(true), Tables\Filters\SelectFilter::make('app_status') ->options([ diff --git a/tests/Feature/Filament/HousekeepingTest.php b/tests/Feature/Filament/HousekeepingTest.php index 25d44b5..3030ae0 100644 --- a/tests/Feature/Filament/HousekeepingTest.php +++ b/tests/Feature/Filament/HousekeepingTest.php @@ -166,6 +166,8 @@ 'name' => 'Tenant 3', ]); + $tenant->makeCurrent(); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'pol-1', @@ -201,6 +203,8 @@ 'name' => 'Tenant 3b', ]); + $tenant->makeCurrent(); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'pol-1b', -- 2.45.2 From e7d2be16f240c754e04e759731fcfcb3304b32c9 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 03:02:56 +0100 Subject: [PATCH 13/21] feat: bulk archive backup sets - Add BulkBackupSetDeleteJob with tenant isolation and skip reasons - Add BackupSetResource bulk archive action with 10+ type-to-confirm - Add unit + feature tests and mark Phase 9 tasks complete --- app/Filament/Resources/BackupSetResource.php | 67 +++++++- app/Jobs/BulkBackupSetDeleteJob.php | 158 +++++++++++++++++++ specs/005-bulk-operations/tasks.md | 14 +- tests/Feature/BulkDeleteBackupSetsTest.php | 81 ++++++++++ tests/Unit/BulkBackupSetDeleteJobTest.php | 91 +++++++++++ 5 files changed, 403 insertions(+), 8 deletions(-) create mode 100644 app/Jobs/BulkBackupSetDeleteJob.php create mode 100644 tests/Feature/BulkDeleteBackupSetsTest.php create mode 100644 tests/Unit/BulkBackupSetDeleteJobTest.php diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index 511cda3..83900d4 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -4,20 +4,27 @@ use App\Filament\Resources\BackupSetResource\Pages; use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager; +use App\Jobs\BulkBackupSetDeleteJob; use App\Models\BackupSet; use App\Models\Tenant; +use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Intune\BackupService; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; +use Filament\Actions\BulkAction; +use Filament\Actions\BulkActionGroup; use Filament\Forms; use Filament\Infolists; use Filament\Notifications\Notification; use Filament\Resources\Resource; use Filament\Schemas\Schema; use Filament\Tables; +use Filament\Tables\Contracts\HasTable; +use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Collection; use UnitEnum; class BackupSetResource extends Resource @@ -135,7 +142,65 @@ public static function table(Table $table): Table }), ])->icon('heroicon-o-ellipsis-vertical'), ]) - ->bulkActions([]); + ->bulkActions([ + BulkActionGroup::make([ + BulkAction::make('bulk_delete') + ->label('Archive Backup Sets') + ->icon('heroicon-o-archive-box-x-mark') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return $isOnlyTrashed; + }) + ->modalDescription('This archives backup sets (soft delete). Backup sets referenced by restore runs will be skipped.') + ->form(function (Collection $records) { + if ($records->count() >= 10) { + return [ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]; + } + + return []; + }) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'backup_set', 'delete', $ids, $count); + + if ($count >= 10) { + Notification::make() + ->title('Bulk archive started') + ->body("Archiving {$count} backup sets in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkBackupSetDeleteJob::dispatch($run->id); + } else { + BulkBackupSetDeleteJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + ]), + ]); } public static function infolist(Schema $schema): Schema diff --git a/app/Jobs/BulkBackupSetDeleteJob.php b/app/Jobs/BulkBackupSetDeleteJob.php new file mode 100644 index 0000000..370bb77 --- /dev/null +++ b/app/Jobs/BulkBackupSetDeleteJob.php @@ -0,0 +1,158 @@ +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 $backupSetId) { + $itemCount++; + + try { + /** @var BackupSet|null $backupSet */ + $backupSet = BackupSet::withTrashed() + ->where('tenant_id', $run->tenant_id) + ->whereKey($backupSetId) + ->first(); + + if (! $backupSet) { + $service->recordFailure($run, (string) $backupSetId, 'Backup set not found'); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Archive Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if ($backupSet->trashed()) { + $service->recordSkippedWithReason($run, (string) $backupSet->id, 'Already archived'); + $skipped++; + $skipReasons['Already archived'] = ($skipReasons['Already archived'] ?? 0) + 1; + + continue; + } + + if ($backupSet->restoreRuns()->withTrashed()->exists()) { + $service->recordSkippedWithReason($run, (string) $backupSet->id, 'Referenced by restore runs'); + $skipped++; + $skipReasons['Referenced by restore runs'] = ($skipReasons['Referenced by restore runs'] ?? 0) + 1; + + continue; + } + + $backupSet->delete(); + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $backupSetId, $e->getMessage()); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Archive Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if (! $run->user) { + return; + } + + $message = "Archived {$succeeded} backup sets"; + if ($skipped > 0) { + $message .= " ({$skipped} skipped)"; + } + if ($failed > 0) { + $message .= " ({$failed} failed)"; + } + + if (! empty($skipReasons)) { + $summary = collect($skipReasons) + ->sortDesc() + ->map(fn (int $count, string $reason) => "{$reason} ({$count})") + ->take(3) + ->implode(', '); + + if ($summary !== '') { + $message .= " Skip reasons: {$summary}."; + } + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Archive Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } +} diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md index e58d7ad..c044d94 100644 --- a/specs/005-bulk-operations/tasks.md +++ b/specs/005-bulk-operations/tasks.md @@ -253,17 +253,17 @@ ## Phase 9: Additional Resource - Bulk Delete Backup Sets (Priority: P2) ### Tests for Additional Resource -- [ ] T078 [P] Write unit test for BulkBackupSetDeleteJob in tests/Unit/BulkBackupSetDeleteJobTest.php -- [ ] T079 [P] Write feature test for bulk delete backup sets in tests/Feature/BulkDeleteBackupSetsTest.php +- [x] T078 [P] Write unit test for BulkBackupSetDeleteJob in tests/Unit/BulkBackupSetDeleteJobTest.php +- [x] T079 [P] Write feature test for bulk delete backup sets in tests/Feature/BulkDeleteBackupSetsTest.php ### Implementation for Additional Resource -- [ ] T080 [P] Create BulkBackupSetDeleteJob in app/Jobs/BulkBackupSetDeleteJob.php -- [ ] T081 Add bulk delete action to BackupSetResource in app/Filament/Resources/BackupSetResource.php -- [ ] T082 Verify cascade-delete logic for backup items (should be automatic via foreign key) -- [ ] T083 Add type-to-confirm for ≥10 sets +- [x] T080 [P] Create BulkBackupSetDeleteJob in app/Jobs/BulkBackupSetDeleteJob.php +- [x] T081 Add bulk delete action to BackupSetResource in app/Filament/Resources/BackupSetResource.php +- [x] T082 Verify cascade-delete logic for backup items (should be automatic via foreign key) +- [x] T083 Add type-to-confirm for ≥10 sets - [ ] T084 Test delete with 15 backup sets (manual QA) -- [ ] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php` +- [x] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php` ### Additional: Bulk Archive Backup Sets (FR-005.23) diff --git a/tests/Feature/BulkDeleteBackupSetsTest.php b/tests/Feature/BulkDeleteBackupSetsTest.php new file mode 100644 index 0000000..eba84c5 --- /dev/null +++ b/tests/Feature/BulkDeleteBackupSetsTest.php @@ -0,0 +1,81 @@ +create(); + $user = User::factory()->create(); + + $sets = collect(range(1, 3))->map(function (int $i) use ($tenant) { + return BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup '.$i, + 'status' => 'completed', + 'item_count' => 1, + ]); + }); + + $sets->each(function (BackupSet $set) use ($tenant) { + BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-'.$set->id, + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + 'payload' => ['id' => 'policy-'.$set->id], + 'metadata' => null, + ]); + }); + + Livewire::actingAs($user) + ->test(BackupSetResource\Pages\ListBackupSets::class) + ->callTableBulkAction('bulk_delete', $sets) + ->assertHasNoTableBulkActionErrors(); + + $sets->each(fn (BackupSet $set) => expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue()); + + $bulkRun = BulkOperationRun::query() + ->where('resource', 'backup_set') + ->where('action', 'delete') + ->latest('id') + ->first(); + + expect($bulkRun)->not->toBeNull(); + expect($bulkRun->status)->toBe('completed'); +}); + +test('backup sets table bulk archive requires type-to-confirm for 10+ sets', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $sets = collect(range(1, 10))->map(function (int $i) use ($tenant) { + return BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup '.$i, + 'status' => 'completed', + 'item_count' => 0, + ]); + }); + + Livewire::actingAs($user) + ->test(BackupSetResource\Pages\ListBackupSets::class) + ->callTableBulkAction('bulk_delete', $sets) + ->assertHasTableBulkActionErrors(); + + Livewire::actingAs($user) + ->test(BackupSetResource\Pages\ListBackupSets::class) + ->callTableBulkAction('bulk_delete', $sets, data: [ + 'confirmation' => 'DELETE', + ]) + ->assertHasNoTableBulkActionErrors(); +}); diff --git a/tests/Unit/BulkBackupSetDeleteJobTest.php b/tests/Unit/BulkBackupSetDeleteJobTest.php new file mode 100644 index 0000000..fa44a5d --- /dev/null +++ b/tests/Unit/BulkBackupSetDeleteJobTest.php @@ -0,0 +1,91 @@ +create(); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 2, + ]); + + $items = collect(range(1, 2))->map(function (int $i) use ($tenant, $set) { + return BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-'.$i, + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + 'payload' => ['id' => 'policy-'.$i], + 'metadata' => null, + ]); + }); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'backup_set', 'delete', [$set->id], 1); + + (new BulkBackupSetDeleteJob($run->id))->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->processed_items)->toBe(1) + ->and($run->succeeded)->toBe(1) + ->and($run->failed)->toBe(0) + ->and($run->skipped)->toBe(0); + + expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue(); + + $items->each(function (BackupItem $item) { + expect(BackupItem::withTrashed()->find($item->id)?->trashed())->toBeTrue(); + }); +}); + +test('bulk backup set delete job skips sets referenced by restore runs', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'backup_set', 'delete', [$set->id], 1); + + (new BulkBackupSetDeleteJob($run->id))->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->processed_items)->toBe(1) + ->and($run->succeeded)->toBe(0) + ->and($run->failed)->toBe(0) + ->and($run->skipped)->toBe(1); + + expect(collect($run->failures)->pluck('reason')->join(' '))->toContain('restore runs'); + expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeFalse(); +}); -- 2.45.2 From 239a2e6af9fcccc02f41fed8983c90384f764909 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 03:09:33 +0100 Subject: [PATCH 14/21] feat: backup sets bulk restore/force delete - Add archived-only bulk restore + force delete actions - Add jobs with tenant isolation + skip reasons - Restore also restores backup items; force delete removes items - Add unit + feature tests --- app/Filament/Resources/BackupSetResource.php | 101 +++++++++++ app/Jobs/BulkBackupSetForceDeleteJob.php | 160 ++++++++++++++++++ app/Jobs/BulkBackupSetRestoreJob.php | 152 +++++++++++++++++ specs/005-bulk-operations/tasks.md | 8 + .../Feature/BulkForceDeleteBackupSetsTest.php | 55 ++++++ tests/Feature/BulkRestoreBackupSetsTest.php | 58 +++++++ .../Unit/BulkBackupSetForceDeleteJobTest.php | 107 ++++++++++++ tests/Unit/BulkBackupSetRestoreJobTest.php | 105 ++++++++++++ 8 files changed, 746 insertions(+) create mode 100644 app/Jobs/BulkBackupSetForceDeleteJob.php create mode 100644 app/Jobs/BulkBackupSetRestoreJob.php create mode 100644 tests/Feature/BulkForceDeleteBackupSetsTest.php create mode 100644 tests/Feature/BulkRestoreBackupSetsTest.php create mode 100644 tests/Unit/BulkBackupSetForceDeleteJobTest.php create mode 100644 tests/Unit/BulkBackupSetRestoreJobTest.php diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index 83900d4..d2bdbdd 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -5,6 +5,8 @@ use App\Filament\Resources\BackupSetResource\Pages; use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager; use App\Jobs\BulkBackupSetDeleteJob; +use App\Jobs\BulkBackupSetForceDeleteJob; +use App\Jobs\BulkBackupSetRestoreJob; use App\Models\BackupSet; use App\Models\Tenant; use App\Services\BulkOperationService; @@ -199,6 +201,105 @@ public static function table(Table $table): Table } }) ->deselectRecordsAfterCompletion(), + + BulkAction::make('bulk_restore') + ->label('Restore Backup Sets') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?") + ->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.') + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'backup_set', 'restore', $ids, $count); + + if ($count >= 10) { + Notification::make() + ->title('Bulk restore started') + ->body("Restoring {$count} backup sets in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkBackupSetRestoreJob::dispatch($run->id); + } else { + BulkBackupSetRestoreJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), + + BulkAction::make('bulk_force_delete') + ->label('Force Delete Backup Sets') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->hidden(function (HasTable $livewire): bool { + $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; + $value = $trashedFilterState['value'] ?? null; + + $isOnlyTrashed = in_array($value, [0, '0', false], true); + + return ! $isOnlyTrashed; + }) + ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?") + ->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.') + ->form(function (Collection $records) { + if ($records->count() >= 10) { + return [ + Forms\Components\TextInput::make('confirmation') + ->label('Type DELETE to confirm') + ->required() + ->in(['DELETE']) + ->validationMessages([ + 'in' => 'Please type DELETE to confirm.', + ]), + ]; + } + + return []; + }) + ->action(function (Collection $records) { + $tenant = Tenant::current(); + $user = auth()->user(); + $count = $records->count(); + $ids = $records->pluck('id')->toArray(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'backup_set', 'force_delete', $ids, $count); + + if ($count >= 10) { + Notification::make() + ->title('Bulk force delete started') + ->body("Force deleting {$count} backup sets in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->info() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkBackupSetForceDeleteJob::dispatch($run->id); + } else { + BulkBackupSetForceDeleteJob::dispatchSync($run->id); + } + }) + ->deselectRecordsAfterCompletion(), ]), ]); } diff --git a/app/Jobs/BulkBackupSetForceDeleteJob.php b/app/Jobs/BulkBackupSetForceDeleteJob.php new file mode 100644 index 0000000..0cd3d22 --- /dev/null +++ b/app/Jobs/BulkBackupSetForceDeleteJob.php @@ -0,0 +1,160 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + $itemCount = 0; + $succeeded = 0; + $failed = 0; + $skipped = 0; + $skipReasons = []; + + $chunkSize = 10; + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $backupSetId) { + $itemCount++; + + try { + /** @var BackupSet|null $backupSet */ + $backupSet = BackupSet::withTrashed() + ->where('tenant_id', $run->tenant_id) + ->whereKey($backupSetId) + ->first(); + + if (! $backupSet) { + $service->recordFailure($run, (string) $backupSetId, 'Backup set not found'); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Force Delete Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if (! $backupSet->trashed()) { + $service->recordSkippedWithReason($run, (string) $backupSet->id, 'Not archived'); + $skipped++; + $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; + + continue; + } + + if ($backupSet->restoreRuns()->withTrashed()->exists()) { + $service->recordSkippedWithReason($run, (string) $backupSet->id, 'Referenced by restore runs'); + $skipped++; + $skipReasons['Referenced by restore runs'] = ($skipReasons['Referenced by restore runs'] ?? 0) + 1; + + continue; + } + + $backupSet->items()->withTrashed()->forceDelete(); + $backupSet->forceDelete(); + + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $backupSetId, $e->getMessage()); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Force Delete Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if (! $run->user) { + return; + } + + $message = "Force deleted {$succeeded} backup sets"; + if ($skipped > 0) { + $message .= " ({$skipped} skipped)"; + } + if ($failed > 0) { + $message .= " ({$failed} failed)"; + } + + if (! empty($skipReasons)) { + $summary = collect($skipReasons) + ->sortDesc() + ->map(fn (int $count, string $reason) => "{$reason} ({$count})") + ->take(3) + ->implode(', '); + + if ($summary !== '') { + $message .= " Skip reasons: {$summary}."; + } + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Force Delete Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } +} diff --git a/app/Jobs/BulkBackupSetRestoreJob.php b/app/Jobs/BulkBackupSetRestoreJob.php new file mode 100644 index 0000000..e028892 --- /dev/null +++ b/app/Jobs/BulkBackupSetRestoreJob.php @@ -0,0 +1,152 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + $itemCount = 0; + $succeeded = 0; + $failed = 0; + $skipped = 0; + $skipReasons = []; + + $chunkSize = 10; + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $backupSetId) { + $itemCount++; + + try { + /** @var BackupSet|null $backupSet */ + $backupSet = BackupSet::withTrashed() + ->where('tenant_id', $run->tenant_id) + ->whereKey($backupSetId) + ->first(); + + if (! $backupSet) { + $service->recordFailure($run, (string) $backupSetId, 'Backup set not found'); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Restore Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if (! $backupSet->trashed()) { + $service->recordSkippedWithReason($run, (string) $backupSet->id, 'Not archived'); + $skipped++; + $skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1; + + continue; + } + + $backupSet->restore(); + $backupSet->items()->withTrashed()->restore(); + + $service->recordSuccess($run); + $succeeded++; + } catch (Throwable $e) { + $service->recordFailure($run, (string) $backupSetId, $e->getMessage()); + $failed++; + + if ($failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Restore Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if (! $run->user) { + return; + } + + $message = "Restored {$succeeded} backup sets"; + if ($skipped > 0) { + $message .= " ({$skipped} skipped)"; + } + if ($failed > 0) { + $message .= " ({$failed} failed)"; + } + + if (! empty($skipReasons)) { + $summary = collect($skipReasons) + ->sortDesc() + ->map(fn (int $count, string $reason) => "{$reason} ({$count})") + ->take(3) + ->implode(', '); + + if ($summary !== '') { + $message .= " Skip reasons: {$summary}."; + } + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Restore Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } +} diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md index c044d94..aad316c 100644 --- a/specs/005-bulk-operations/tasks.md +++ b/specs/005-bulk-operations/tasks.md @@ -265,6 +265,14 @@ ### Implementation for Additional Resource - [ ] T084 Test delete with 15 backup sets (manual QA) - [x] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php` +- [x] T085e Add bulk restore backup sets job in app/Jobs/BulkBackupSetRestoreJob.php +- [x] T085f Add bulk restore action (archived-only) to BackupSetResource in app/Filament/Resources/BackupSetResource.php +- [x] T085g Write unit+feature tests for bulk restore in tests/Unit/BulkBackupSetRestoreJobTest.php and tests/Feature/BulkRestoreBackupSetsTest.php + +- [x] T085h Add bulk force delete backup sets job in app/Jobs/BulkBackupSetForceDeleteJob.php +- [x] T085i Add bulk force delete action (archived-only) to BackupSetResource in app/Filament/Resources/BackupSetResource.php +- [x] T085j Write unit+feature tests for bulk force delete in tests/Unit/BulkBackupSetForceDeleteJobTest.php and tests/Feature/BulkForceDeleteBackupSetsTest.php + ### Additional: Bulk Archive Backup Sets (FR-005.23) - [ ] T085a Verify BackupSet has `archived_at` column; add migration if missing in database/migrations/ diff --git a/tests/Feature/BulkForceDeleteBackupSetsTest.php b/tests/Feature/BulkForceDeleteBackupSetsTest.php new file mode 100644 index 0000000..fcb9946 --- /dev/null +++ b/tests/Feature/BulkForceDeleteBackupSetsTest.php @@ -0,0 +1,55 @@ +create(); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $item = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + 'payload' => ['id' => 'policy-1'], + 'metadata' => null, + ]); + + $set->delete(); + + Livewire::actingAs($user) + ->test(BackupSetResource\Pages\ListBackupSets::class) + ->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false) + ->callTableBulkAction('bulk_force_delete', collect([$set])) + ->assertHasNoTableBulkActionErrors(); + + expect(BackupSet::withTrashed()->find($set->id))->toBeNull(); + expect(BackupItem::withTrashed()->find($item->id))->toBeNull(); + + $bulkRun = BulkOperationRun::query() + ->where('resource', 'backup_set') + ->where('action', 'force_delete') + ->latest('id') + ->first(); + + expect($bulkRun)->not->toBeNull(); + expect($bulkRun->status)->toBe('completed'); +}); diff --git a/tests/Feature/BulkRestoreBackupSetsTest.php b/tests/Feature/BulkRestoreBackupSetsTest.php new file mode 100644 index 0000000..3cda81b --- /dev/null +++ b/tests/Feature/BulkRestoreBackupSetsTest.php @@ -0,0 +1,58 @@ +create(); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $item = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + 'payload' => ['id' => 'policy-1'], + 'metadata' => null, + ]); + + $set->delete(); + + Livewire::actingAs($user) + ->test(BackupSetResource\Pages\ListBackupSets::class) + ->filterTable(\Filament\Tables\Filters\TrashedFilter::class, false) + ->callTableBulkAction('bulk_restore', collect([$set])) + ->assertHasNoTableBulkActionErrors(); + + $set->refresh(); + $item->refresh(); + + expect($set->trashed())->toBeFalse(); + expect($item->trashed())->toBeFalse(); + + $bulkRun = BulkOperationRun::query() + ->where('resource', 'backup_set') + ->where('action', 'restore') + ->latest('id') + ->first(); + + expect($bulkRun)->not->toBeNull(); + expect($bulkRun->status)->toBe('completed'); +}); diff --git a/tests/Unit/BulkBackupSetForceDeleteJobTest.php b/tests/Unit/BulkBackupSetForceDeleteJobTest.php new file mode 100644 index 0000000..7a2720a --- /dev/null +++ b/tests/Unit/BulkBackupSetForceDeleteJobTest.php @@ -0,0 +1,107 @@ +create(['is_current' => true]); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $item = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + 'payload' => ['id' => 'policy-1'], + 'metadata' => null, + ]); + + $set->delete(); + + $service = app(BulkOperationService::class); + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'resource' => 'backup_set', + 'action' => 'force_delete', + 'status' => 'pending', + 'total_items' => 1, + 'item_ids' => [$set->id], + 'failures' => [], + ]); + + (new BulkBackupSetForceDeleteJob($run->id))->handle($service); + + expect(BackupSet::withTrashed()->find($set->id))->toBeNull(); + expect(BackupItem::withTrashed()->find($item->id))->toBeNull(); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->succeeded)->toBe(1) + ->and($run->skipped)->toBe(0) + ->and($run->failed)->toBe(0); +}); + +test('bulk backup set force delete job skips sets referenced by restore runs', function () { + $tenant = Tenant::factory()->create(['is_current' => true]); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $set->delete(); + + RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $service = app(BulkOperationService::class); + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'resource' => 'backup_set', + 'action' => 'force_delete', + 'status' => 'pending', + 'total_items' => 1, + 'item_ids' => [$set->id], + 'failures' => [], + ]); + + (new BulkBackupSetForceDeleteJob($run->id))->handle($service); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->succeeded)->toBe(0) + ->and($run->skipped)->toBe(1) + ->and($run->failed)->toBe(0); + + expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue(); + expect(collect($run->failures)->pluck('reason')->all())->toContain('Referenced by restore runs'); +}); diff --git a/tests/Unit/BulkBackupSetRestoreJobTest.php b/tests/Unit/BulkBackupSetRestoreJobTest.php new file mode 100644 index 0000000..7966e4c --- /dev/null +++ b/tests/Unit/BulkBackupSetRestoreJobTest.php @@ -0,0 +1,105 @@ +create(['is_current' => true]); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $item = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + 'payload' => ['id' => 'policy-1'], + 'metadata' => null, + ]); + + $set->delete(); + + $set->refresh(); + expect($set->trashed())->toBeTrue(); + expect(BackupItem::withTrashed()->find($item->id)?->trashed())->toBeTrue(); + + $service = app(BulkOperationService::class); + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'resource' => 'backup_set', + 'action' => 'restore', + 'status' => 'pending', + 'total_items' => 1, + 'item_ids' => [$set->id], + 'failures' => [], + ]); + + (new BulkBackupSetRestoreJob($run->id))->handle($service); + + $set->refresh(); + expect($set->trashed())->toBeFalse(); + + $item->refresh(); + expect($item->trashed())->toBeFalse(); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->succeeded)->toBe(1) + ->and($run->skipped)->toBe(0) + ->and($run->failed)->toBe(0); +}); + +test('bulk backup set restore job skips active sets', function () { + $tenant = Tenant::factory()->create(['is_current' => true]); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $service = app(BulkOperationService::class); + $run = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'resource' => 'backup_set', + 'action' => 'restore', + 'status' => 'pending', + 'total_items' => 1, + 'item_ids' => [$set->id], + 'failures' => [], + ]); + + (new BulkBackupSetRestoreJob($run->id))->handle($service); + + $set->refresh(); + expect($set->trashed())->toBeFalse(); + + $run->refresh(); + expect($run->status)->toBe('completed') + ->and($run->succeeded)->toBe(0) + ->and($run->skipped)->toBe(1) + ->and($run->failed)->toBe(0); + + expect(collect($run->failures)->pluck('reason')->all())->toContain('Not archived'); +}); -- 2.45.2 From eef9618889fdf6b9d97079e614e39434146e28e9 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 03:18:12 +0100 Subject: [PATCH 15/21] feat: configurable bulk ops polling + chunking - Add tenantpilot.bulk_operations config (chunk size, poll interval) - Use config chunk size across all bulk jobs - Make progress widget polling interval configurable - Document settings in README + feature quickstart; mark tasks done --- README.md | 11 +++++++++++ app/Jobs/BulkBackupSetDeleteJob.php | 2 +- app/Jobs/BulkBackupSetForceDeleteJob.php | 2 +- app/Jobs/BulkBackupSetRestoreJob.php | 2 +- app/Jobs/BulkPolicyDeleteJob.php | 4 ++-- app/Jobs/BulkPolicyExportJob.php | 2 +- app/Jobs/BulkPolicySyncJob.php | 2 +- app/Jobs/BulkPolicyUnignoreJob.php | 2 +- app/Jobs/BulkPolicyVersionForceDeleteJob.php | 2 +- app/Jobs/BulkPolicyVersionPruneJob.php | 2 +- app/Jobs/BulkPolicyVersionRestoreJob.php | 2 +- app/Jobs/BulkRestoreRunDeleteJob.php | 2 +- app/Jobs/BulkRestoreRunForceDeleteJob.php | 2 +- app/Jobs/BulkRestoreRunRestoreJob.php | 2 +- app/Livewire/BulkOperationProgress.php | 3 +++ config/tenantpilot.php | 5 +++++ .../views/livewire/bulk-operation-progress.blade.php | 2 +- specs/005-bulk-operations/quickstart.md | 11 +++++++++++ specs/005-bulk-operations/tasks.md | 8 ++++---- 19 files changed, 49 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e9213f9..77c54c9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,17 @@ ## TenantPilot setup - Ensure queue workers are running for jobs (e.g., policy sync) after deploy. - Keep secrets/env in Dokploy, never in code. +## Bulk operations (Feature 005) + +- Bulk actions are available in Filament resource tables (Policies, Policy Versions, Backup Sets, Restore Runs). +- Destructive operations require type-to-confirm at higher thresholds (e.g. `DELETE`). +- Long-running bulk ops are queued; the bottom-right progress widget polls for active runs. + +### Configuration + +- `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`): job refresh/progress chunk size. +- `TENANTPILOT_BULK_POLL_INTERVAL_SECONDS` (default `3`): Livewire polling interval for the progress widget (clamped to 1–10s). + ## Intune RBAC Onboarding Wizard - Entry point: Tenant detail in Filament (`Setup Intune RBAC` in the ⋯ ActionGroup). Visible only for active tenants with `app_client_id`. diff --git a/app/Jobs/BulkBackupSetDeleteJob.php b/app/Jobs/BulkBackupSetDeleteJob.php index 370bb77..22bc167 100644 --- a/app/Jobs/BulkBackupSetDeleteJob.php +++ b/app/Jobs/BulkBackupSetDeleteJob.php @@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkBackupSetForceDeleteJob.php b/app/Jobs/BulkBackupSetForceDeleteJob.php index 0cd3d22..b53a1e4 100644 --- a/app/Jobs/BulkBackupSetForceDeleteJob.php +++ b/app/Jobs/BulkBackupSetForceDeleteJob.php @@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkBackupSetRestoreJob.php b/app/Jobs/BulkBackupSetRestoreJob.php index e028892..0d39cea 100644 --- a/app/Jobs/BulkBackupSetRestoreJob.php +++ b/app/Jobs/BulkBackupSetRestoreJob.php @@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkPolicyDeleteJob.php b/app/Jobs/BulkPolicyDeleteJob.php index 4cf6827..20fb6b1 100644 --- a/app/Jobs/BulkPolicyDeleteJob.php +++ b/app/Jobs/BulkPolicyDeleteJob.php @@ -44,7 +44,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $failures = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); @@ -129,7 +129,7 @@ public function handle(BulkOperationService $service): void } - // Refresh the run from database every 10 items to avoid stale data + // Refresh the run from database every $chunkSize items to avoid stale data if ($itemCount % $chunkSize === 0) { diff --git a/app/Jobs/BulkPolicyExportJob.php b/app/Jobs/BulkPolicyExportJob.php index eabfe37..76b51c3 100644 --- a/app/Jobs/BulkPolicyExportJob.php +++ b/app/Jobs/BulkPolicyExportJob.php @@ -51,7 +51,7 @@ public function handle(BulkOperationService $service): void $succeeded = 0; $failed = 0; $failures = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkPolicySyncJob.php b/app/Jobs/BulkPolicySyncJob.php index 559eaf5..0ec2f4d 100644 --- a/app/Jobs/BulkPolicySyncJob.php +++ b/app/Jobs/BulkPolicySyncJob.php @@ -31,7 +31,7 @@ public function handle(BulkOperationService $service, PolicySyncService $syncSer $service->start($run); try { - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $itemCount = 0; $totalItems = $run->total_items ?: count($run->item_ids ?? []); diff --git a/app/Jobs/BulkPolicyUnignoreJob.php b/app/Jobs/BulkPolicyUnignoreJob.php index b07a5cc..edbf4eb 100644 --- a/app/Jobs/BulkPolicyUnignoreJob.php +++ b/app/Jobs/BulkPolicyUnignoreJob.php @@ -35,7 +35,7 @@ public function handle(BulkOperationService $service): void $failed = 0; $skipped = 0; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); foreach ($run->item_ids as $policyId) { $itemCount++; diff --git a/app/Jobs/BulkPolicyVersionForceDeleteJob.php b/app/Jobs/BulkPolicyVersionForceDeleteJob.php index 6c40173..2275408 100644 --- a/app/Jobs/BulkPolicyVersionForceDeleteJob.php +++ b/app/Jobs/BulkPolicyVersionForceDeleteJob.php @@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkPolicyVersionPruneJob.php b/app/Jobs/BulkPolicyVersionPruneJob.php index 2bcce1d..d8009cf 100644 --- a/app/Jobs/BulkPolicyVersionPruneJob.php +++ b/app/Jobs/BulkPolicyVersionPruneJob.php @@ -38,7 +38,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkPolicyVersionRestoreJob.php b/app/Jobs/BulkPolicyVersionRestoreJob.php index 7170820..6e6c17d 100644 --- a/app/Jobs/BulkPolicyVersionRestoreJob.php +++ b/app/Jobs/BulkPolicyVersionRestoreJob.php @@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkRestoreRunDeleteJob.php b/app/Jobs/BulkRestoreRunDeleteJob.php index e5e3ec3..4864e8e 100644 --- a/app/Jobs/BulkRestoreRunDeleteJob.php +++ b/app/Jobs/BulkRestoreRunDeleteJob.php @@ -38,7 +38,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkRestoreRunForceDeleteJob.php b/app/Jobs/BulkRestoreRunForceDeleteJob.php index d96d5a0..5f8dbc5 100644 --- a/app/Jobs/BulkRestoreRunForceDeleteJob.php +++ b/app/Jobs/BulkRestoreRunForceDeleteJob.php @@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Jobs/BulkRestoreRunRestoreJob.php b/app/Jobs/BulkRestoreRunRestoreJob.php index 8bf4229..2b8efe4 100644 --- a/app/Jobs/BulkRestoreRunRestoreJob.php +++ b/app/Jobs/BulkRestoreRunRestoreJob.php @@ -37,7 +37,7 @@ public function handle(BulkOperationService $service): void $skipped = 0; $skipReasons = []; - $chunkSize = 10; + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); $totalItems = $run->total_items ?: count($run->item_ids ?? []); $failureThreshold = (int) floor($totalItems / 2); diff --git a/app/Livewire/BulkOperationProgress.php b/app/Livewire/BulkOperationProgress.php index c8e6193..975a619 100644 --- a/app/Livewire/BulkOperationProgress.php +++ b/app/Livewire/BulkOperationProgress.php @@ -11,8 +11,11 @@ class BulkOperationProgress extends Component { public $runs; + public int $pollSeconds = 3; + public function mount() { + $this->pollSeconds = max(1, min(10, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3))); $this->loadRuns(); } diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 6f64d4d..6de7643 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -118,4 +118,9 @@ 'features' => [ 'conditional_access' => true, ], + + 'bulk_operations' => [ + 'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10), + 'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3), + ], ]; diff --git a/resources/views/livewire/bulk-operation-progress.blade.php b/resources/views/livewire/bulk-operation-progress.blade.php index 18c0289..c254211 100644 --- a/resources/views/livewire/bulk-operation-progress.blade.php +++ b/resources/views/livewire/bulk-operation-progress.blade.php @@ -1,4 +1,4 @@ -
+
@if($runs->isNotEmpty())
diff --git a/specs/005-bulk-operations/quickstart.md b/specs/005-bulk-operations/quickstart.md index 000c74f..5712cea 100644 --- a/specs/005-bulk-operations/quickstart.md +++ b/specs/005-bulk-operations/quickstart.md @@ -120,6 +120,17 @@ ### Browser Tests (Pest v4) --- +## Configuration + +These defaults are safe for staging/production, but can be tuned per environment. + +- **Chunk size** (job refresh/progress cadence): + - `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`) +- **Progress polling interval** (UI updates): + - `TENANTPILOT_BULK_POLL_INTERVAL_SECONDS` (default `3`, clamped to 1–10 seconds) +- **Policy version prune retention window**: + - Default `90` days (editable in the prune modal as “Retention Days”) + ## Manual Testing Workflow ### Scenario 1: Bulk Delete Policies (< 20 items) diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md index aad316c..c2932cc 100644 --- a/specs/005-bulk-operations/tasks.md +++ b/specs/005-bulk-operations/tasks.md @@ -166,7 +166,7 @@ ### Implementation for User Story 6 - [ ] T053 [US6] Test circuit breaker with mock failures (manual QA) - [x] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php` -- [ ] T054a [US6] Define/verify polling interval meets NFR-005.3 (≤10s updates) and document where configured +- [x] T054a [US6] Define/verify polling interval meets NFR-005.3 (≤10s updates) and document where configured - [ ] T054b [US6] Manual QA: force a catastrophic job failure and verify FR-005.13 notification behavior - [ ] T054c [US6] Manual QA: verify UI remains responsive during a 100-item queued run (NFR-005.4) @@ -288,8 +288,8 @@ ## Phase 10: Polish & Cross-Cutting Concerns **Purpose**: Documentation, cleanup, performance optimization -- [ ] T086 [P] Update README.md with bulk operations feature description -- [ ] T087 [P] Update quickstart.md with manual testing scenarios (already done in planning) +- [x] T086 [P] Update README.md with bulk operations feature description +- [x] T087 [P] Update quickstart.md with manual testing scenarios (already done in planning) - [ ] T088 Code cleanup: Remove debug statements, refactor duplicated logic - [ ] T089 Performance test: Bulk delete 500 policies (should complete <5 minutes) - [ ] T090 Load test: Concurrent bulk operations (2-3 admins, different resources) @@ -298,7 +298,7 @@ ## Phase 10: Polish & Cross-Cutting Concerns - [ ] T093 Run full test suite: `./vendor/bin/sail artisan test` - [ ] T094 Run Pint formatting: `./vendor/bin/sail composer pint` - [ ] T095 Manual QA checklist: Complete all scenarios from quickstart.md -- [ ] T096 Document configuration options (chunk size, polling interval, retention days) +- [x] T096 Document configuration options (chunk size, polling interval, retention days) - [ ] T097 Create BulkOperationRun resource page in Filament (view progress, retry failed) - [ ] T098 Add bulk operation metrics to dashboard (total runs, success rate) -- 2.45.2 From 0a6e1f7751be4e71000380183503df9ee61d0904 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 03:23:26 +0100 Subject: [PATCH 16/21] fix: allow deleting partial restore runs Normalize RestoreRun status in isDeletable() so 'Partial'/'completed-with-errors' variants can be archived. --- app/Models/RestoreRun.php | 5 ++++- tests/Unit/RestoreRunDeletableTest.php | 30 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index ce7fb50..9160725 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -41,6 +41,9 @@ public function scopeDeletable($query) public function isDeletable(): bool { - return in_array($this->status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial'], true); + $status = strtolower(trim((string) $this->status)); + $status = str_replace([' ', '-'], '_', $status); + + return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial'], true); } } diff --git a/tests/Unit/RestoreRunDeletableTest.php b/tests/Unit/RestoreRunDeletableTest.php index 0ea051c..4db56f6 100644 --- a/tests/Unit/RestoreRunDeletableTest.php +++ b/tests/Unit/RestoreRunDeletableTest.php @@ -54,3 +54,33 @@ 'partial', ]); }); + +test('isDeletable accepts partial even if status casing/format differs', function () { + $tenant = Tenant::factory()->create(); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $partial = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'Partial', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $completedWithErrors = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed-with-errors', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + expect($partial->isDeletable())->toBeTrue(); + expect($completedWithErrors->isDeletable())->toBeTrue(); +}); -- 2.45.2 From d718a127c528857b58d212949acdb4e8314be6ba Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 03:25:27 +0100 Subject: [PATCH 17/21] fix: allow deleting previewed restore runs Treat previewed as deletable for hygiene; keeps partial deletable too. --- app/Models/RestoreRun.php | 4 ++-- tests/Unit/RestoreRunDeletableTest.php | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index 9160725..2e93372 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -36,7 +36,7 @@ public function backupSet(): BelongsTo public function scopeDeletable($query) { - return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial']); + return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed']); } public function isDeletable(): bool @@ -44,6 +44,6 @@ public function isDeletable(): bool $status = strtolower(trim((string) $this->status)); $status = str_replace([' ', '-'], '_', $status); - return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial'], true); + return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed'], true); } } diff --git a/tests/Unit/RestoreRunDeletableTest.php b/tests/Unit/RestoreRunDeletableTest.php index 4db56f6..fa61f1c 100644 --- a/tests/Unit/RestoreRunDeletableTest.php +++ b/tests/Unit/RestoreRunDeletableTest.php @@ -24,6 +24,7 @@ 'aborted', 'completed_with_errors', 'partial', + 'previewed', 'running', 'pending', ]; @@ -52,6 +53,7 @@ 'completed_with_errors', 'failed', 'partial', + 'previewed', ]); }); -- 2.45.2 From 59b229cbad8a515a70f7578dc6027b3374016a8f Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 03:36:11 +0100 Subject: [PATCH 18/21] chore(spec): mark bulk-ops QA tasks complete --- specs/005-bulk-operations/tasks.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md index c2932cc..db57c7e 100644 --- a/specs/005-bulk-operations/tasks.md +++ b/specs/005-bulk-operations/tasks.md @@ -113,7 +113,7 @@ ### Implementation for Bulk Sync Policies - [x] T035c Add bulk sync action to PolicyResource in app/Filament/Resources/PolicyResource.php - [x] T035d Dispatch SyncPoliciesJob for selected policies (choose per-ID or batched IDs) in app/Jobs/SyncPoliciesJob.php (or a new dedicated bulk sync job) -- [ ] T035e Manual QA: bulk sync 25 policies (verify queued processing + notifications) +- [x] T035e Manual QA: bulk sync 25 policies (verify queued processing + notifications) **Checkpoint**: Bulk sync action queues work and respects permissions @@ -133,9 +133,9 @@ ### Tests for User Story 5 ### Validation for User Story 5 -- [ ] T039 [US5] Manual test: Bulk delete 10 policies → confirm without typing (should work) -- [ ] T040 [US5] Manual test: Bulk delete 25 policies → type "delete" → button stays disabled -- [ ] T041 [US5] Manual test: Bulk delete 25 policies → type "DELETE" → button enables, operation proceeds +- [x] T039 [US5] Manual test: Bulk delete 10 policies → confirm without typing (should work) +- [x] T040 [US5] Manual test: Bulk delete 25 policies → type "delete" → button stays disabled +- [x] T041 [US5] Manual test: Bulk delete 25 policies → type "DELETE" → button enables, operation proceeds - [x] T042 [US5] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkTypeToConfirmTest.php` **Checkpoint**: Type-to-confirm working correctly for all thresholds @@ -162,13 +162,13 @@ ### Implementation for User Story 6 - [x] T049 [US6] Update BulkPolicyExportJob to emit progress after each chunk - [x] T050 [US6] Implement circuit breaker logic (abort if >50% fail) in jobs - [x] T051 [US6] Add progress polling to Filament notifications or sidebar widget -- [ ] T052 [US6] Test progress with 100 policies (manual QA, observe updates) -- [ ] T053 [US6] Test circuit breaker with mock failures (manual QA) +- [x] T052 [US6] Test progress with 100 policies (manual QA, observe updates) +- [x] T053 [US6] Test circuit breaker with mock failures (manual QA) - [x] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php` - [x] T054a [US6] Define/verify polling interval meets NFR-005.3 (≤10s updates) and document where configured -- [ ] T054b [US6] Manual QA: force a catastrophic job failure and verify FR-005.13 notification behavior -- [ ] T054c [US6] Manual QA: verify UI remains responsive during a 100-item queued run (NFR-005.4) +- [x] T054b [US6] Manual QA: force a catastrophic job failure and verify FR-005.13 notification behavior +- [x] T054c [US6] Manual QA: verify UI remains responsive during a 100-item queued run (NFR-005.4) **Checkpoint**: Progress tracking working, polling updates UI, circuit breaker aborts high-failure jobs @@ -194,7 +194,7 @@ ### Implementation for User Story 3 - [x] T061 [US3] Implement eligibility check per version in job (reuse pruneEligible scope) - [x] T062 [US3] Collect skip reasons for ineligible versions - [x] T063 [US3] Add type-to-confirm for ≥20 versions -- [ ] T064 [US3] Test prune with 30 versions (15 eligible, 15 ineligible) - manual QA +- [x] T064 [US3] Test prune with 30 versions (15 eligible, 15 ineligible) - manual QA - [x] T065 [US3] Verify skip reasons in notification and audit log - [x] T066 [US3] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkPruneVersionsTest.php` @@ -230,7 +230,7 @@ ### Implementation for User Story 4 - [x] T073 [US4] Filter by deletable() scope (completed, failed, aborted only) - [x] T074 [US4] Skip running restore runs with warning - [x] T075 [US4] Add type-to-confirm for ≥20 runs -- [ ] T076 [US4] Test delete with 20 completed + 5 running (manual QA, verify 5 skipped) +- [x] T076 [US4] Test delete with 20 completed + 5 running (manual QA, verify 5 skipped) - [x] T077 [US4] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteRestoreRunsTest.php` - [x] T077a [US4] Add bulk force delete restore runs job in app/Jobs/BulkRestoreRunForceDeleteJob.php @@ -262,7 +262,7 @@ ### Implementation for Additional Resource - [x] T081 Add bulk delete action to BackupSetResource in app/Filament/Resources/BackupSetResource.php - [x] T082 Verify cascade-delete logic for backup items (should be automatic via foreign key) - [x] T083 Add type-to-confirm for ≥10 sets -- [ ] T084 Test delete with 15 backup sets (manual QA) +- [x] T084 Test delete with 15 backup sets (manual QA) - [x] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php` - [x] T085e Add bulk restore backup sets job in app/Jobs/BulkBackupSetRestoreJob.php @@ -295,9 +295,9 @@ ## Phase 10: Polish & Cross-Cutting Concerns - [ ] T090 Load test: Concurrent bulk operations (2-3 admins, different resources) - [ ] T091 [P] Security review: Verify tenant isolation in all jobs - [ ] T092 [P] Permission audit: Verify all bulk actions respect RBAC -- [ ] T093 Run full test suite: `./vendor/bin/sail artisan test` -- [ ] T094 Run Pint formatting: `./vendor/bin/sail composer pint` -- [ ] T095 Manual QA checklist: Complete all scenarios from quickstart.md +- [x] T093 Run full test suite: `./vendor/bin/sail artisan test` +- [x] T094 Run Pint formatting: `./vendor/bin/sail composer pint` +- [x] T095 Manual QA checklist: Complete all scenarios from quickstart.md - [x] T096 Document configuration options (chunk size, polling interval, retention days) - [ ] T097 Create BulkOperationRun resource page in Filament (view progress, retry failed) - [ ] T098 Add bulk operation metrics to dashboard (total runs, success rate) -- 2.45.2 From 70fd5d2e68bf486c84c0899895b34a5d4519a93a Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 13:27:17 +0100 Subject: [PATCH 19/21] fix: stabilize tests and spec plan artifacts --- .github/agents/copilot-instructions.md | 3 + .../contracts/openapi.yaml | 12 + specs/005-bulk-operations/plan.md | 262 +++--------------- .../spec.md | 0 tests/Feature/BulkDeleteBackupSetsTest.php | 2 + tests/Feature/BulkDeleteMixedStatusTest.php | 1 + tests/Feature/BulkDeleteRestoreRunsTest.php | 1 + .../Feature/BulkForceDeleteBackupSetsTest.php | 1 + .../BulkForceDeleteRestoreRunsTest.php | 1 + tests/Feature/BulkRestoreBackupSetsTest.php | 1 + tests/Feature/BulkRestoreRestoreRunsTest.php | 1 + tests/Feature/BulkSyncPoliciesTest.php | 23 +- tests/Feature/BulkTypeToConfirmTest.php | 3 + .../PolicyVersionViewAssignmentsTest.php | 4 + tests/Pest.php | 5 + tests/Unit/BulkActionPermissionTest.php | 1 + 16 files changed, 92 insertions(+), 229 deletions(-) create mode 100644 specs/005-bulk-operations/contracts/openapi.yaml rename specs/{005-policy-lifecycle => 900-policy-lifecycle}/spec.md (100%) diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 62118e0..1a9df86 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -3,6 +3,8 @@ # TenantAtlas Development Guidelines Auto-generated from all feature plans. Last updated: 2025-12-22 ## Active Technologies +- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations) +- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations) @@ -22,6 +24,7 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- feat/005-bulk-operations: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 - feat/005-bulk-operations: Added PHP 8.4.15 diff --git a/specs/005-bulk-operations/contracts/openapi.yaml b/specs/005-bulk-operations/contracts/openapi.yaml new file mode 100644 index 0000000..4a08977 --- /dev/null +++ b/specs/005-bulk-operations/contracts/openapi.yaml @@ -0,0 +1,12 @@ +openapi: 3.0.3 +info: + title: TenantPilot - Bulk Operations (Feature 005) + version: 0.0.0 + description: | + This feature is implemented via Filament/Livewire actions inside the admin panel. + No public, stable HTTP API endpoints are introduced specifically for bulk operations. + + This OpenAPI document is intentionally minimal. +servers: [] +paths: {} +components: {} diff --git a/specs/005-bulk-operations/plan.md b/specs/005-bulk-operations/plan.md index 2455f5b..9790f69 100644 --- a/specs/005-bulk-operations/plan.md +++ b/specs/005-bulk-operations/plan.md @@ -1,82 +1,38 @@ # Implementation Plan: Feature 005 - Bulk Operations -**Branch**: `feat/005-bulk-operations` | **Date**: 2025-12-22 | **Spec**: [spec.md](./spec.md) +**Branch**: `feat/005-bulk-operations` | **Date**: 2025-12-25 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/005-bulk-operations/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. ## Summary -Enable efficient bulk operations (delete, export, prune) across TenantPilot's main resources (Policies, Policy Versions, Backup Sets, Restore Runs) with safety gates, progress tracking, and comprehensive audit logging. Technical approach: Filament bulk actions + Laravel Queue jobs with chunked processing + BulkOperationRun tracking model + Livewire polling for progress updates. +Add consistent bulk actions (delete/export/restore/prune/sync where applicable) across TenantPilot's primary admin resources (Policies, Policy Versions, Backup Sets, Restore Runs). Bulk operations create a tracking record, enforce permissions, support type-to-confirm for large destructive changes, and run asynchronously via queue for larger selections with progress tracking. ## Technical Context + **Language/Version**: PHP 8.4.15 -**Framework**: Laravel 12 -**Primary Dependencies**: -- Filament v4 (admin panel + bulk actions) -- Livewire v3 (reactive UI + polling) -- Laravel Queue (async job processing) -- PostgreSQL (JSONB for tracking) - -**Storage**: PostgreSQL with JSONB fields for: -- `bulk_operation_runs.item_ids` (array of resource IDs) -- `bulk_operation_runs.failures` (per-item error details) -- Existing audit logs (metadata column) - -**Testing**: Pest v4 (unit, feature, browser tests) -**Target Platform**: Web (Dokploy deployment) -**Project Type**: Web application (Filament admin panel) - -**Performance Goals**: -- Process 100 items in <2 minutes (queued) -- Handle up to 500 items per operation without timeout -- Progress notifications update every 5-10 seconds - -**Constraints**: -- Queue jobs MUST process in chunks of 10-20 items (memory efficiency) -- Progress tracking requires explicit polling (not automatic in Filament) -- Type-to-confirm required for ≥20 destructive items -- Tenant isolation enforced at job level - -**Scale/Scope**: -- 4 primary resources (Policies, PolicyVersions, BackupSets, RestoreRuns) -- 8-12 bulk actions (P1/P2 priority) -- Estimated 26-34 hours implementation (3 phases for P1/P2) +**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3 +**Storage**: PostgreSQL (app), SQLite in-memory (tests) +**Testing**: Pest v4 + PHPUnit 12 +**Target Platform**: Containerized Linux (Sail/Dokploy) +**Project Type**: Web application (Laravel + Filament admin panel) +**Performance Goals**: Handle bulk actions up to hundreds of items with predictable runtime; keep UI responsive via queued processing for larger selections +**Constraints**: Tenant isolation; least privilege; safe destructive actions (confirmation + auditability); avoid long locks/timeouts by chunking +**Scale/Scope**: Admin-focused operations, moderate concurrency, emphasis on correctness/auditability over throughput ## Constitution Check *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* -**Note**: Project constitution is template-only (not populated). Using Laravel/TenantPilot conventions instead. +The constitution file at `.specify/memory/constitution.md` is a placeholder template (no concrete principles/gates are defined). For this feature, the effective gates follow repository agent guidelines in `Agents.md`: -### Architecture Principles +- Spec artifacts exist and are consistent: PASS (`spec.md`, `plan.md`, `tasks.md`, `research.md`, `data-model.md`, `quickstart.md`) +- Tests cover changes: PASS (Pest suite; full test run exits 0) +- Safe admin operations: PASS (explicit confirmations, type-to-confirm for large destructive ops, audit logging) -✅ **Library-First**: N/A (feature extends existing app, no new libraries) -✅ **Test-First**: TDD enforced - Pest tests required before implementation -✅ **Simplicity**: Uses existing patterns (Jobs, Filament bulk actions, Livewire polling) -✅ **Sail-First**: Local development uses Laravel Sail (Docker) -✅ **Dokploy Deployment**: Production/staging via Dokploy (VPS containers) - -### Laravel Conventions - -✅ **PSR-12**: Code formatting enforced via Laravel Pint -✅ **Eloquent-First**: No raw DB queries, use Model::query() patterns -✅ **Permission Gates**: Leverage existing RBAC (Feature 001) -✅ **Queue Jobs**: Use ShouldQueue interface, chunked processing -✅ **Audit Logging**: Extend existing AuditLog model/service - -### Safety Requirements - -✅ **Tenant Isolation**: Job constructor accepts explicit `tenantId` -✅ **Audit Trail**: One audit log entry per bulk operation + per-item outcomes -✅ **Confirmation**: Type-to-confirm for ≥20 destructive items -✅ **Fail-Soft**: Continue processing on individual failures, abort if >50% fail -✅ **Immutability**: Policy Versions check eligibility before prune (referenced, current, age) - -### Gates - -🔒 **GATE-01**: Bulk operations MUST use existing permission model (policies.delete, etc.) -🔒 **GATE-02**: Progress tracking MUST use BulkOperationRun model (not fire-and-forget) -🔒 **GATE-03**: Type-to-confirm MUST be case-sensitive "DELETE" for ≥20 items -🔒 **GATE-04**: Policies bulk delete = local only (ignored_at flag, NO Graph DELETE) +Re-check after Phase 1: PASS (no new unknowns introduced). ## Project Structure @@ -84,180 +40,42 @@ ### Documentation (this feature) ```text specs/005-bulk-operations/ -├── plan.md # This file -├── research.md # Phase 0 output (see below) -├── data-model.md # Phase 1 output (see below) -├── quickstart.md # Phase 1 output (see below) -└── tasks.md # Phase 2 output (/speckit.tasks command - generated and tracked here) +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) ``` ### Source Code (repository root) ```text app/ -├── Models/ -│ ├── BulkOperationRun.php # NEW: Tracks progress/outcomes -│ ├── Policy.php # EXTEND: Add markIgnored() scope -│ ├── PolicyVersion.php # EXTEND: Add pruneEligible() scope -│ ├── BackupSet.php # EXTEND: Cascade delete logic -│ └── RestoreRun.php # EXTEND: Skip running status -│ -├── Jobs/ -│ ├── BulkPolicyDeleteJob.php # NEW: Async bulk delete (local) -│ ├── BulkPolicyExportJob.php # NEW: Export to backup set -│ ├── BulkPolicyVersionPruneJob.php # NEW: Prune old versions -│ ├── BulkBackupSetDeleteJob.php # NEW: Delete backup sets -│ └── BulkRestoreRunDeleteJob.php # NEW: Delete restore runs -│ -├── Services/ -│ ├── BulkOperationService.php # NEW: Orchestrates bulk ops + tracking -│ └── Audit/ -│ └── AuditLogger.php # EXTEND: Add bulk operation events -│ ├── Filament/ │ └── Resources/ -│ ├── PolicyResource.php # EXTEND: Add bulk actions -│ ├── PolicyVersionResource.php # EXTEND: Add bulk prune -│ ├── BackupSetResource.php # EXTEND: Add bulk delete -│ └── RestoreRunResource.php # EXTEND: Add bulk delete -│ -└── Livewire/ - └── BulkOperationProgress.php # NEW: Progress polling component +├── Jobs/ +├── Models/ +└── Services/ database/ +├── factories/ └── migrations/ - └── YYYY_MM_DD_create_bulk_operation_runs_table.php # NEW + +routes/ +├── web.php +└── console.php + +resources/ +└── views/ tests/ -├── Unit/ -│ ├── BulkPolicyDeleteJobTest.php -│ ├── BulkActionPermissionTest.php -│ └── BulkEligibilityCheckTest.php -│ -└── Feature/ - ├── BulkDeletePoliciesTest.php - ├── BulkExportToBackupTest.php - ├── BulkProgressNotificationTest.php - └── BulkTypeToConfirmTest.php +├── Feature/ +└── Unit/ ``` -**Structure Decision**: Single web application structure (Laravel + Filament). New bulk operations extend existing Resources with BulkAction definitions. New BulkOperationRun model tracks async job progress. No separate API layer needed (Livewire polling uses Filament infolists/resource pages). +**Structure Decision**: Web application (Laravel + Filament admin panel) using existing repository layout. ## Complexity Tracking -> No constitution violations requiring justification. - ---- - -## Phase 0: Research & Technology Decisions - -See [research.md](./research.md) for detailed research findings. - -### Key Decisions Summary - -| Decision | Chosen | Rationale | -|----------|--------|-----------| -| Progress tracking | BulkOperationRun model + Livewire polling | Explicit state, survives page refresh, queryable outcomes | -| Job chunking | collect()->chunk(10) | Simple, memory-efficient, easy to test | -| Type-to-confirm | Filament form + validation rule | Built-in UI, reusable pattern | -| Tenant isolation | Explicit tenantId param | Fail-safe, auditable, no reliance on global scopes | -| Policy deletion | ignored_at flag | Prevents re-sync, restorable, doesn't touch Intune | -| Eligibility checks | Eloquent scopes | Reusable, testable, composable | - ---- - -## Phase 1: Data Model & Contracts - -See [data-model.md](./data-model.md) for detailed schemas and entity diagrams. - -### Core Entities - -**BulkOperationRun** (NEW): -- Tracks progress, outcomes, failures for bulk operations -- Fields: resource, action, status, total_items, processed_items, succeeded, failed, skipped -- JSONB: item_ids, failures -- Relationships: tenant, user, auditLog - -**Policy** (EXTEND): -- Add `ignored_at` timestamp (prevents re-sync) -- Add `markIgnored()` method and `notIgnored()` scope - -**PolicyVersion** (EXTEND): -- Add `pruneEligible()` scope (checks age, references, current status) - -**RestoreRun** (EXTEND): -- Add `deletable()` scope (filters by completed/failed status) - ---- - -## Phase 2: Implementation Tasks - -Detailed tasks will be generated via `/speckit.tasks` command. High-level phases: - -### Phase 2.1: Foundation (P1 - Policies) - 8-12 hours -- BulkOperationRun migration + model -- Policies: ignored_at column, bulk delete/export jobs -- Filament bulk actions + type-to-confirm -- BulkOperationService orchestration -- Tests (unit, feature) - -### Phase 2.2: Progress Tracking (P1) - 8-10 hours -- Livewire progress component -- Job progress updates (chunked) -- Circuit breaker (>50% fail abort) -- Audit logging integration -- Tests (progress, polling, audit) - -### Phase 2.3: Additional Resources (P2) - 6-8 hours -- PolicyVersion prune (eligibility scope) -- BackupSet bulk delete -- RestoreRun bulk delete -- Resource extensions -- Tests for each resource - -### Phase 2.4: Polish & Deployment - 4-6 hours -- Manual QA (type-to-confirm, progress UI) -- Load testing (500 items) -- Documentation updates -- Staging → Production deployment - ---- - -## Risk Mitigation - -| Risk | Mitigation | -|------|------------| -| Queue timeouts | Chunk processing (10-20 items), timeout config (300s), circuit breaker | -| Progress polling overhead | Limit interval (5s), index queries, cache recent runs | -| Accidental deletes | Type-to-confirm ≥20 items, `ignored_at` flag (restorable), audit trail | -| Job crashes | Fail-soft, BulkOperationRun status tracking, Laravel retry | -| Eligibility misses | Conservative JSONB queries, manual review before hard delete | -| Sync re-adds policies | `ignored_at` filter in SyncPoliciesJob | - ---- - -## Success Criteria - -- ✅ Bulk delete 100 policies in <2 minutes -- ✅ Type-to-confirm prevents accidents (≥20 items) -- ✅ Progress updates every 5-10s -- ✅ Audit log captures per-item outcomes -- ✅ 95%+ operation success rate -- ✅ All P1/P2 tests pass - ---- - -## Next Steps - -1. ✅ Generate plan.md (this file) -2. → Generate research.md (detailed technology findings) -3. → Generate data-model.md (schemas + diagrams) -4. → Generate quickstart.md (developer onboarding) -5. → Run `/speckit.tasks` to create task breakdown -6. → Begin Phase 2.1 implementation - ---- - -**Status**: Plan Complete - Ready for Research -**Created**: 2025-12-22 -**Last Updated**: 2025-12-22 +No constitution violations requiring justification. diff --git a/specs/005-policy-lifecycle/spec.md b/specs/900-policy-lifecycle/spec.md similarity index 100% rename from specs/005-policy-lifecycle/spec.md rename to specs/900-policy-lifecycle/spec.md diff --git a/tests/Feature/BulkDeleteBackupSetsTest.php b/tests/Feature/BulkDeleteBackupSetsTest.php index eba84c5..1dc7427 100644 --- a/tests/Feature/BulkDeleteBackupSetsTest.php +++ b/tests/Feature/BulkDeleteBackupSetsTest.php @@ -13,6 +13,7 @@ test('backup sets table bulk archive creates a run and archives selected sets', function () { $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); $user = User::factory()->create(); $sets = collect(range(1, 3))->map(function (int $i) use ($tenant) { @@ -56,6 +57,7 @@ test('backup sets table bulk archive requires type-to-confirm for 10+ sets', function () { $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); $user = User::factory()->create(); $sets = collect(range(1, 10))->map(function (int $i) use ($tenant) { diff --git a/tests/Feature/BulkDeleteMixedStatusTest.php b/tests/Feature/BulkDeleteMixedStatusTest.php index be7dbbb..17dad2b 100644 --- a/tests/Feature/BulkDeleteMixedStatusTest.php +++ b/tests/Feature/BulkDeleteMixedStatusTest.php @@ -13,6 +13,7 @@ test('bulk delete restore runs skips running items', function () { $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); $user = User::factory()->create(); $backupSet = BackupSet::create([ diff --git a/tests/Feature/BulkDeleteRestoreRunsTest.php b/tests/Feature/BulkDeleteRestoreRunsTest.php index 1711219..9e41b4b 100644 --- a/tests/Feature/BulkDeleteRestoreRunsTest.php +++ b/tests/Feature/BulkDeleteRestoreRunsTest.php @@ -13,6 +13,7 @@ test('bulk delete restore runs soft deletes selected runs', function () { $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); $user = User::factory()->create(); $backupSet = BackupSet::create([ diff --git a/tests/Feature/BulkForceDeleteBackupSetsTest.php b/tests/Feature/BulkForceDeleteBackupSetsTest.php index fcb9946..7cb8aec 100644 --- a/tests/Feature/BulkForceDeleteBackupSetsTest.php +++ b/tests/Feature/BulkForceDeleteBackupSetsTest.php @@ -13,6 +13,7 @@ test('backup sets table bulk force delete permanently deletes archived sets and their items', function () { $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); $user = User::factory()->create(); $set = BackupSet::create([ diff --git a/tests/Feature/BulkForceDeleteRestoreRunsTest.php b/tests/Feature/BulkForceDeleteRestoreRunsTest.php index 3e3a583..d527954 100644 --- a/tests/Feature/BulkForceDeleteRestoreRunsTest.php +++ b/tests/Feature/BulkForceDeleteRestoreRunsTest.php @@ -13,6 +13,7 @@ test('bulk force delete restore runs permanently deletes archived runs', function () { $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); $user = User::factory()->create(); $backupSet = BackupSet::create([ diff --git a/tests/Feature/BulkRestoreBackupSetsTest.php b/tests/Feature/BulkRestoreBackupSetsTest.php index 3cda81b..3908e6d 100644 --- a/tests/Feature/BulkRestoreBackupSetsTest.php +++ b/tests/Feature/BulkRestoreBackupSetsTest.php @@ -13,6 +13,7 @@ test('backup sets table bulk restore restores archived sets and their items', function () { $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); $user = User::factory()->create(); $set = BackupSet::create([ diff --git a/tests/Feature/BulkRestoreRestoreRunsTest.php b/tests/Feature/BulkRestoreRestoreRunsTest.php index 58d696f..35d2bec 100644 --- a/tests/Feature/BulkRestoreRestoreRunsTest.php +++ b/tests/Feature/BulkRestoreRestoreRunsTest.php @@ -13,6 +13,7 @@ test('restore runs table bulk restore creates a run and restores archived records', function () { $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); $user = User::factory()->create(); $backupSet = BackupSet::create([ diff --git a/tests/Feature/BulkSyncPoliciesTest.php b/tests/Feature/BulkSyncPoliciesTest.php index ad50805..6e976e0 100644 --- a/tests/Feature/BulkSyncPoliciesTest.php +++ b/tests/Feature/BulkSyncPoliciesTest.php @@ -1,19 +1,21 @@ create(); + $tenant->makeCurrent(); $user = User::factory()->create(); $policies = Policy::factory() @@ -25,7 +27,7 @@ 'last_synced_at' => null, ]); - app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface + app()->instance(GraphClientInterface::class, new class implements GraphClientInterface { public function listPolicies(string $policyType, array $options = []): GraphResponse { @@ -65,10 +67,17 @@ public function request(string $method, string $path, array $options = []): Grap } }); - Livewire::actingAs($user) - ->test(PolicyResource\Pages\ListPolicies::class) - ->callTableBulkAction('bulk_sync', $policies) - ->assertHasNoTableBulkActionErrors(); + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'sync', $policies->modelKeys(), 3); + + BulkPolicySyncJob::dispatchSync($run->id); + + $bulkRun = BulkOperationRun::query()->find($run->id); + expect($bulkRun)->not->toBeNull(); + expect($bulkRun->status)->toBe('completed'); + expect($bulkRun->total_items)->toBe(3); + expect($bulkRun->succeeded)->toBe(3); + expect($bulkRun->failed)->toBe(0); $policies->each(function (Policy $policy) { $policy->refresh(); diff --git a/tests/Feature/BulkTypeToConfirmTest.php b/tests/Feature/BulkTypeToConfirmTest.php index 978748a..1b7748a 100644 --- a/tests/Feature/BulkTypeToConfirmTest.php +++ b/tests/Feature/BulkTypeToConfirmTest.php @@ -11,6 +11,7 @@ test('bulk delete requires confirmation string for large batches', function () { $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); $user = User::factory()->create(); $policies = Policy::factory()->count(20)->create(['tenant_id' => $tenant->id]); @@ -26,6 +27,7 @@ test('bulk delete fails with incorrect confirmation string', function () { $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); $user = User::factory()->create(); $policies = Policy::factory()->count(20)->create(['tenant_id' => $tenant->id]); @@ -41,6 +43,7 @@ test('bulk delete does not require confirmation string for small batches', function () { $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); $user = User::factory()->create(); $policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]); diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php index eb977a6..239ff72 100644 --- a/tests/Feature/PolicyVersionViewAssignmentsTest.php +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -6,7 +6,11 @@ use App\Models\User; beforeEach(function () { + putenv('INTUNE_TENANT_ID'); + unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']); + $this->tenant = Tenant::factory()->create(); + $this->tenant->makeCurrent(); $this->policy = Policy::factory()->create([ 'tenant_id' => $this->tenant->id, ]); diff --git a/tests/Pest.php b/tests/Pest.php index d03d034..4baf965 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -17,6 +17,11 @@ ->use(RefreshDatabase::class) ->in('Feature'); +beforeEach(function () { + putenv('INTUNE_TENANT_ID'); + unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']); +}); + /* |-------------------------------------------------------------------------- | Expectations diff --git a/tests/Unit/BulkActionPermissionTest.php b/tests/Unit/BulkActionPermissionTest.php index e6e8986..a9da5b9 100644 --- a/tests/Unit/BulkActionPermissionTest.php +++ b/tests/Unit/BulkActionPermissionTest.php @@ -12,6 +12,7 @@ test('policies bulk actions are available for authenticated users', function () { $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); $user = User::factory()->create(); $policies = Policy::factory()->count(2)->create(['tenant_id' => $tenant->id]); -- 2.45.2 From 160c5e42a9e58f763f131d996bc6b6e465904fa3 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 13:41:54 +0100 Subject: [PATCH 20/21] fix: allow archiving backup sets with restore runs --- app/Filament/Resources/BackupSetResource.php | 12 +------- app/Jobs/BulkBackupSetDeleteJob.php | 8 ------ app/Models/RestoreRun.php | 2 +- tests/Feature/BulkDeleteBackupSetsTest.php | 30 ++++++++++++++++++++ tests/Feature/Filament/HousekeepingTest.php | 9 +++--- tests/Unit/BulkBackupSetDeleteJobTest.php | 10 +++---- 6 files changed, 42 insertions(+), 29 deletions(-) diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index c2dee2a..890898a 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -78,16 +78,6 @@ public static function table(Table $table): Table ->requiresConfirmation() ->visible(fn (BackupSet $record) => ! $record->trashed()) ->action(function (BackupSet $record, AuditLogger $auditLogger) { - if ($record->restoreRuns()->withTrashed()->exists()) { - Notification::make() - ->title('Cannot archive backup set') - ->body('Backup sets used by restore runs cannot be archived.') - ->danger() - ->send(); - - return; - } - $record->delete(); if ($record->tenant) { @@ -159,7 +149,7 @@ public static function table(Table $table): Table return $isOnlyTrashed; }) - ->modalDescription('This archives backup sets (soft delete). Backup sets referenced by restore runs will be skipped.') + ->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.') ->form(function (Collection $records) { if ($records->count() >= 10) { return [ diff --git a/app/Jobs/BulkBackupSetDeleteJob.php b/app/Jobs/BulkBackupSetDeleteJob.php index 22bc167..fce556e 100644 --- a/app/Jobs/BulkBackupSetDeleteJob.php +++ b/app/Jobs/BulkBackupSetDeleteJob.php @@ -82,14 +82,6 @@ public function handle(BulkOperationService $service): void continue; } - if ($backupSet->restoreRuns()->withTrashed()->exists()) { - $service->recordSkippedWithReason($run, (string) $backupSet->id, 'Referenced by restore runs'); - $skipped++; - $skipReasons['Referenced by restore runs'] = ($skipReasons['Referenced by restore runs'] ?? 0) + 1; - - continue; - } - $backupSet->delete(); $service->recordSuccess($run); $succeeded++; diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index fe6bdcf..28945c4 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -32,7 +32,7 @@ public function tenant(): BelongsTo public function backupSet(): BelongsTo { - return $this->belongsTo(BackupSet::class); + return $this->belongsTo(BackupSet::class)->withTrashed(); } public function scopeDeletable($query) diff --git a/tests/Feature/BulkDeleteBackupSetsTest.php b/tests/Feature/BulkDeleteBackupSetsTest.php index 1dc7427..260b458 100644 --- a/tests/Feature/BulkDeleteBackupSetsTest.php +++ b/tests/Feature/BulkDeleteBackupSetsTest.php @@ -4,6 +4,7 @@ use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\BulkOperationRun; +use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -55,6 +56,35 @@ expect($bulkRun->status)->toBe('completed'); }); +test('backup sets can be archived even when referenced by restore runs', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + $user = User::factory()->create(); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + Livewire::actingAs($user) + ->test(BackupSetResource\Pages\ListBackupSets::class) + ->callTableBulkAction('bulk_delete', collect([$set])) + ->assertHasNoTableBulkActionErrors(); + + expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue(); + expect(RestoreRun::withTrashed()->find($restoreRun->id))->not->toBeNull(); +}); + test('backup sets table bulk archive requires type-to-confirm for 10+ sets', function () { $tenant = Tenant::factory()->create(); $tenant->makeCurrent(); diff --git a/tests/Feature/Filament/HousekeepingTest.php b/tests/Feature/Filament/HousekeepingTest.php index 3030ae0..5967be1 100644 --- a/tests/Feature/Filament/HousekeepingTest.php +++ b/tests/Feature/Filament/HousekeepingTest.php @@ -53,7 +53,7 @@ ]); }); -test('backup set archive is blocked when restore runs exist', function () { +test('backup set can be archived when restore runs exist', function () { $tenant = Tenant::create([ 'tenant_id' => 'tenant-2', 'name' => 'Tenant 2', @@ -65,7 +65,7 @@ 'status' => 'completed', ]); - RestoreRun::create([ + $restoreRun = RestoreRun::create([ 'tenant_id' => $tenant->id, 'backup_set_id' => $backupSet->id, 'status' => 'completed', @@ -77,12 +77,13 @@ Livewire::test(ListBackupSets::class) ->callTableAction('archive', $backupSet); - $this->assertDatabaseMissing('audit_logs', [ + $this->assertSoftDeleted('backup_sets', ['id' => $backupSet->id]); + $this->assertDatabaseHas('audit_logs', [ 'resource_type' => 'backup_set', 'resource_id' => (string) $backupSet->id, 'action' => 'backup.deleted', ]); - $this->assertDatabaseHas('backup_sets', ['id' => $backupSet->id, 'deleted_at' => null]); + $this->assertDatabaseHas('restore_runs', ['id' => $restoreRun->id]); }); test('backup set can be force deleted when trashed and unused', function () { diff --git a/tests/Unit/BulkBackupSetDeleteJobTest.php b/tests/Unit/BulkBackupSetDeleteJobTest.php index fa44a5d..03c9e4c 100644 --- a/tests/Unit/BulkBackupSetDeleteJobTest.php +++ b/tests/Unit/BulkBackupSetDeleteJobTest.php @@ -55,7 +55,7 @@ }); }); -test('bulk backup set delete job skips sets referenced by restore runs', function () { +test('bulk backup set delete job archives sets even when referenced by restore runs', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(); @@ -82,10 +82,10 @@ $run->refresh(); expect($run->status)->toBe('completed') ->and($run->processed_items)->toBe(1) - ->and($run->succeeded)->toBe(0) + ->and($run->succeeded)->toBe(1) ->and($run->failed)->toBe(0) - ->and($run->skipped)->toBe(1); + ->and($run->skipped)->toBe(0); - expect(collect($run->failures)->pluck('reason')->join(' '))->toContain('restore runs'); - expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeFalse(); + expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue(); + expect(RestoreRun::query()->where('backup_set_id', $set->id)->exists())->toBeTrue(); }); -- 2.45.2 From 5b59988d485a89f14e21d7b752016e7c56ff4008 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 25 Dec 2025 14:26:50 +0100 Subject: [PATCH 21/21] fix: add missing single-row restore/actions --- app/Filament/Resources/BackupSetResource.php | 26 ++++ app/Filament/Resources/PolicyResource.php | 65 ++++++++++ app/Filament/Resources/RestoreRunResource.php | 25 ++++ tests/Feature/Filament/HousekeepingTest.php | 111 ++++++++++++++++++ 4 files changed, 227 insertions(+) diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index 890898a..b0d5664 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -71,6 +71,32 @@ public static function table(Table $table): Table ->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record])) ->openUrlInNewTab(false), ActionGroup::make([ + Actions\Action::make('restore') + ->label('Restore') + ->color('success') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->visible(fn (BackupSet $record) => $record->trashed()) + ->action(function (BackupSet $record, AuditLogger $auditLogger) { + $record->restore(); + $record->items()->withTrashed()->restore(); + + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'backup.restored', + resourceType: 'backup_set', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['name' => $record->name]] + ); + } + + Notification::make() + ->title('Backup set restored') + ->success() + ->send(); + }), Actions\Action::make('archive') ->label('Archive') ->color('danger') diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index acfe7f1..b2d2ed5 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -14,6 +14,7 @@ use App\Services\Intune\PolicyNormalizer; use BackedEnum; use Filament\Actions; +use Filament\Actions\ActionGroup; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Forms; @@ -332,6 +333,70 @@ public static function table(Table $table): Table ]) ->actions([ Actions\ViewAction::make(), + ActionGroup::make([ + Actions\Action::make('ignore') + ->label('Ignore') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (Policy $record) => $record->ignored_at === null) + ->action(function (Policy $record) { + $record->ignore(); + + Notification::make() + ->title('Policy ignored') + ->success() + ->send(); + }), + Actions\Action::make('restore') + ->label('Restore') + ->icon('heroicon-o-arrow-uturn-left') + ->color('success') + ->requiresConfirmation() + ->visible(fn (Policy $record) => $record->ignored_at !== null) + ->action(function (Policy $record) { + $record->unignore(); + + Notification::make() + ->title('Policy restored') + ->success() + ->send(); + }), + Actions\Action::make('sync') + ->label('Sync') + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->requiresConfirmation() + ->visible(fn (Policy $record) => $record->ignored_at === null) + ->action(function (Policy $record) { + $tenant = Tenant::current(); + $user = auth()->user(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'sync', [$record->id], 1); + + BulkPolicySyncJob::dispatchSync($run->id); + }), + Actions\Action::make('export') + ->label('Export to Backup') + ->icon('heroicon-o-archive-box-arrow-down') + ->visible(fn (Policy $record) => $record->ignored_at === null) + ->form([ + Forms\Components\TextInput::make('backup_name') + ->label('Backup Name') + ->required() + ->default(fn () => 'Backup '.now()->toDateTimeString()), + ]) + ->action(function (Policy $record, array $data) { + $tenant = Tenant::current(); + $user = auth()->user(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'policy', 'export', [$record->id], 1); + + BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']); + }), + ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ BulkActionGroup::make([ diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 4482068..12c136b 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -182,6 +182,31 @@ public static function table(Table $table): Table ->actions([ Actions\ViewAction::make(), ActionGroup::make([ + Actions\Action::make('restore') + ->label('Restore') + ->color('success') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->visible(fn (RestoreRun $record) => $record->trashed()) + ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + $record->restore(); + + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'restore_run.restored', + resourceType: 'restore_run', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] + ); + } + + Notification::make() + ->title('Restore run restored') + ->success() + ->send(); + }), Actions\Action::make('archive') ->label('Archive') ->color('danger') diff --git a/tests/Feature/Filament/HousekeepingTest.php b/tests/Feature/Filament/HousekeepingTest.php index 5967be1..9693d8b 100644 --- a/tests/Feature/Filament/HousekeepingTest.php +++ b/tests/Feature/Filament/HousekeepingTest.php @@ -1,6 +1,7 @@ 'tenant-restore-backup-set', + 'name' => 'Tenant Restore Backup Set', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Set restore', + 'status' => 'completed', + ]); + + BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-restore', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows', + 'payload' => ['id' => 'policy-restore'], + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(ListBackupSets::class) + ->callTableAction('archive', $backupSet) + ->set('tableFilters.trashed.value', 1) + ->callTableAction('restore', $backupSet); + + $this->assertDatabaseHas('backup_sets', ['id' => $backupSet->id, 'deleted_at' => null]); + $this->assertDatabaseHas('backup_items', ['backup_set_id' => $backupSet->id, 'deleted_at' => null]); + $this->assertDatabaseHas('audit_logs', [ + 'resource_type' => 'backup_set', + 'resource_id' => (string) $backupSet->id, + 'action' => 'backup.restored', + ]); +}); + test('restore run can be archived and force deleted', function () { $tenant = Tenant::create([ 'tenant_id' => 'tenant-restore-run', @@ -161,6 +201,77 @@ ]); }); +test('restore run can be restored when archived', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-restore-restore-run', + 'name' => 'Tenant Restore Restore Run', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Set for restore run restore', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(ListRestoreRuns::class) + ->callTableAction('archive', $restoreRun) + ->set('tableFilters.trashed.value', 1) + ->callTableAction('restore', $restoreRun); + + $this->assertDatabaseHas('restore_runs', ['id' => $restoreRun->id, 'deleted_at' => null]); + $this->assertDatabaseHas('audit_logs', [ + 'resource_type' => 'restore_run', + 'resource_id' => (string) $restoreRun->id, + 'action' => 'restore_run.restored', + ]); +}); + +test('policy can be ignored and restored via row actions', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-policy-row-actions', + 'name' => 'Tenant Policy Row Actions', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-row-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Row Action Policy', + 'platform' => 'windows', + 'last_synced_at' => now(), + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(ListPolicies::class) + ->callTableAction('ignore', $policy); + + $policy->refresh(); + expect($policy->ignored_at)->not->toBeNull(); + + Livewire::test(ListPolicies::class) + ->set('tableFilters.visibility.value', 'ignored') + ->callTableAction('restore', $policy); + + $policy->refresh(); + expect($policy->ignored_at)->toBeNull(); +}); + test('policy version can be archived with audit log', function () { $tenant = Tenant::create([ 'tenant_id' => 'tenant-3', -- 2.45.2