From 68695cd024bb299df98248dbc3857bc7d1c62a02 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Fri, 9 Jan 2026 16:17:00 +0100 Subject: [PATCH] feat(046): inventory sync policy type selection - Use searchable multi-select with select all/clear\n- Track bulk progress per policy type\n- Update tests and spec tasks/quickstart --- app/Filament/Pages/InventoryLanding.php | 178 ++++++++++++++ .../Resources/InventorySyncRunResource.php | 8 + app/Jobs/RunInventorySyncJob.php | 221 ++++++++++++++++++ app/Models/InventorySyncRun.php | 7 + .../Inventory/InventorySyncService.php | 168 ++++++++++++- ...d_user_id_to_inventory_sync_runs_table.php | 35 +++ .../contracts/internal-actions.md | 33 +++ specs/046-inventory-sync-button/data-model.md | 71 ++++++ specs/046-inventory-sync-button/plan.md | 140 +++++++++++ specs/046-inventory-sync-button/quickstart.md | 35 +++ specs/046-inventory-sync-button/research.md | 63 +++++ specs/046-inventory-sync-button/spec.md | 24 +- specs/046-inventory-sync-button/tasks.md | 170 ++++++++++++++ .../Inventory/InventorySyncButtonTest.php | 142 +++++++++++ .../Inventory/RunInventorySyncJobTest.php | 117 ++++++++++ 15 files changed, 1391 insertions(+), 21 deletions(-) create mode 100644 app/Jobs/RunInventorySyncJob.php create mode 100644 database/migrations/2026_01_09_010348_add_user_id_to_inventory_sync_runs_table.php create mode 100644 specs/046-inventory-sync-button/contracts/internal-actions.md create mode 100644 specs/046-inventory-sync-button/data-model.md create mode 100644 specs/046-inventory-sync-button/plan.md create mode 100644 specs/046-inventory-sync-button/quickstart.md create mode 100644 specs/046-inventory-sync-button/research.md create mode 100644 specs/046-inventory-sync-button/tasks.md create mode 100644 tests/Feature/Inventory/InventorySyncButtonTest.php create mode 100644 tests/Feature/Inventory/RunInventorySyncJobTest.php diff --git a/app/Filament/Pages/InventoryLanding.php b/app/Filament/Pages/InventoryLanding.php index 71771c9..425f5e8 100644 --- a/app/Filament/Pages/InventoryLanding.php +++ b/app/Filament/Pages/InventoryLanding.php @@ -4,9 +4,21 @@ use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventorySyncRunResource; +use App\Jobs\RunInventorySyncJob; +use App\Models\InventorySyncRun; use App\Models\Tenant; +use App\Models\User; +use App\Services\BulkOperationService; +use App\Services\Intune\AuditLogger; +use App\Services\Inventory\InventorySyncService; use BackedEnum; +use Filament\Actions\Action; +use Filament\Actions\Action as HintAction; +use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\Select; +use Filament\Notifications\Notification; use Filament\Pages\Page; +use Filament\Support\Enums\Size; use UnitEnum; class InventoryLanding extends Page @@ -19,6 +31,172 @@ class InventoryLanding extends Page protected string $view = 'filament.pages.inventory-landing'; + protected function getHeaderActions(): array + { + return [ + Action::make('run_inventory_sync') + ->label('Run Inventory Sync') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->form([ + Select::make('policy_types') + ->label('Policy types') + ->multiple() + ->searchable() + ->preload() + ->native(false) + ->hintActions([ + fn (Select $component): HintAction => HintAction::make('select_all_policy_types') + ->label('Select all') + ->link() + ->size(Size::Small) + ->action(function (InventorySyncService $inventorySyncService) use ($component): void { + $component->state($inventorySyncService->defaultSelectionPayload()['policy_types']); + }), + fn (Select $component): HintAction => HintAction::make('clear_policy_types') + ->label('Clear') + ->link() + ->size(Size::Small) + ->action(function () use ($component): void { + $component->state([]); + }), + ]) + ->options(function (): array { + return collect(config('tenantpilot.supported_policy_types', [])) + ->filter(fn (array $meta): bool => filled($meta['type'] ?? null)) + ->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other')) + ->mapWithKeys(function ($items, string $category): array { + $options = collect($items) + ->mapWithKeys(function (array $meta): array { + $type = (string) $meta['type']; + $label = (string) ($meta['label'] ?? $type); + $platform = (string) ($meta['platform'] ?? 'all'); + + return [$type => "{$label} • {$platform}"]; + }) + ->all(); + + return [$category => $options]; + }) + ->all(); + }) + ->default([]) + ->dehydrated() + ->required() + ->rules([ + 'array', + 'min:1', + new \App\Rules\SupportedPolicyTypesRule, + ]) + ->columnSpanFull(), + Hidden::make('tenant_id') + ->default(fn (): ?string => Tenant::current()?->getKey()) + ->dehydrated(), + ]) + ->visible(function (): bool { + $user = auth()->user(); + if (! $user instanceof User) { + return false; + } + + return $user->canSyncTenant(Tenant::current()); + }) + ->action(function (array $data, BulkOperationService $bulkOperationService, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + $user = auth()->user(); + if (! $user instanceof User) { + abort(403, 'Not allowed'); + } + + if (! $user->canSyncTenant($tenant)) { + abort(403, 'Not allowed'); + } + + $requestedTenantId = $data['tenant_id'] ?? null; + if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) { + Notification::make() + ->title('Not allowed') + ->danger() + ->send(); + + abort(403, 'Not allowed'); + } + + $selectionPayload = $inventorySyncService->defaultSelectionPayload(); + if (array_key_exists('policy_types', $data)) { + $selectionPayload['policy_types'] = $data['policy_types']; + } + $computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload); + + $existing = InventorySyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_hash', $computed['selection_hash']) + ->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING]) + ->first(); + + if ($existing instanceof InventorySyncRun) { + Notification::make() + ->title('Sync already running') + ->body('An inventory sync is already running for this tenant. Check the progress widget for status.') + ->warning() + ->send(); + + return; + } + + $run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']); + + $policyTypes = $computed['selection']['policy_types'] ?? []; + if (! is_array($policyTypes)) { + $policyTypes = []; + } + + $bulkRun = $bulkOperationService->createRun( + tenant: $tenant, + user: $user, + resource: 'inventory', + action: 'sync', + itemIds: $policyTypes, + totalItems: count($policyTypes), + ); + + $auditLogger->log( + tenant: $tenant, + action: 'inventory.sync.dispatched', + context: [ + 'metadata' => [ + 'inventory_sync_run_id' => $run->id, + 'bulk_run_id' => $bulkRun->id, + 'selection_hash' => $run->selection_hash, + ], + ], + actorId: $user->id, + actorEmail: $user->email, + actorName: $user->name, + resourceType: 'inventory_sync_run', + resourceId: (string) $run->id, + ); + + Notification::make() + ->title('Inventory sync started') + ->body('Sync dispatched. Check the bottom-right progress widget for status.') + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->success() + ->sendToDatabase($user) + ->send(); + + RunInventorySyncJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + bulkRunId: (int) $bulkRun->getKey(), + inventorySyncRunId: (int) $run->getKey(), + ); + }), + ]; + } + public function getInventoryItemsUrl(): string { return InventoryItemResource::getUrl('index', tenant: Tenant::current()); diff --git a/app/Filament/Resources/InventorySyncRunResource.php b/app/Filament/Resources/InventorySyncRunResource.php index c238eb2..0de2629 100644 --- a/app/Filament/Resources/InventorySyncRunResource.php +++ b/app/Filament/Resources/InventorySyncRunResource.php @@ -36,6 +36,9 @@ public static function infolist(Schema $schema): Schema ->schema([ Section::make('Sync Run') ->schema([ + TextEntry::make('user.name') + ->label('Initiator') + ->placeholder('—'), TextEntry::make('status') ->badge() ->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)), @@ -82,6 +85,10 @@ public static function table(Table $table): Table return $table ->defaultSort('id', 'desc') ->columns([ + Tables\Columns\TextColumn::make('user.name') + ->label('Initiator') + ->placeholder('—') + ->toggleable(), Tables\Columns\TextColumn::make('status') ->badge() ->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)), @@ -112,6 +119,7 @@ public static function getEloquentQuery(): Builder $tenantId = Tenant::current()->getKey(); return parent::getEloquentQuery() + ->with('user') ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)); } diff --git a/app/Jobs/RunInventorySyncJob.php b/app/Jobs/RunInventorySyncJob.php new file mode 100644 index 0000000..8eeaf84 --- /dev/null +++ b/app/Jobs/RunInventorySyncJob.php @@ -0,0 +1,221 @@ +find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new RuntimeException('Tenant not found.'); + } + + $user = User::query()->find($this->userId); + if (! $user instanceof User) { + throw new RuntimeException('User not found.'); + } + + $bulkRun = BulkOperationRun::query()->find($this->bulkRunId); + if (! $bulkRun instanceof BulkOperationRun) { + throw new RuntimeException('BulkOperationRun not found.'); + } + + $run = InventorySyncRun::query()->find($this->inventorySyncRunId); + if (! $run instanceof InventorySyncRun) { + throw new RuntimeException('InventorySyncRun not found.'); + } + + $bulkOperationService->start($bulkRun); + + $processedPolicyTypes = []; + + $run = $inventorySyncService->executePendingRun( + $run, + $tenant, + function (string $policyType, bool $success, ?string $errorCode) use ($bulkOperationService, $bulkRun, &$processedPolicyTypes): void { + $processedPolicyTypes[] = $policyType; + + if ($success) { + $bulkOperationService->recordSuccess($bulkRun); + + return; + } + + $bulkOperationService->recordFailure($bulkRun, $policyType, $errorCode ?? 'failed'); + }, + ); + + $policyTypes = is_array($bulkRun->item_ids ?? null) ? $bulkRun->item_ids : []; + if ($policyTypes === []) { + $policyTypes = is_array($run->selection_payload['policy_types'] ?? null) ? $run->selection_payload['policy_types'] : []; + } + + if ($run->status === InventorySyncRun::STATUS_SUCCESS) { + $bulkOperationService->complete($bulkRun); + + $auditLogger->log( + tenant: $tenant, + action: 'inventory.sync.completed', + context: [ + 'metadata' => [ + 'inventory_sync_run_id' => $run->id, + 'bulk_run_id' => $bulkRun->id, + 'selection_hash' => $run->selection_hash, + 'observed' => $run->items_observed_count, + 'upserted' => $run->items_upserted_count, + ], + ], + actorId: $user->id, + actorEmail: $user->email, + actorName: $user->name, + resourceType: 'inventory_sync_run', + resourceId: (string) $run->id, + ); + + Notification::make() + ->title('Inventory sync completed') + ->body('Inventory sync finished successfully.') + ->success() + ->sendToDatabase($user) + ->send(); + + return; + } + + if ($run->status === InventorySyncRun::STATUS_PARTIAL) { + $bulkOperationService->complete($bulkRun); + + $auditLogger->log( + tenant: $tenant, + action: 'inventory.sync.partial', + context: [ + 'metadata' => [ + 'inventory_sync_run_id' => $run->id, + 'bulk_run_id' => $bulkRun->id, + 'selection_hash' => $run->selection_hash, + 'observed' => $run->items_observed_count, + 'upserted' => $run->items_upserted_count, + 'errors' => $run->errors_count, + ], + ], + actorId: $user->id, + actorEmail: $user->email, + actorName: $user->name, + status: 'failure', + resourceType: 'inventory_sync_run', + resourceId: (string) $run->id, + ); + + Notification::make() + ->title('Inventory sync completed with errors') + ->body('Inventory sync finished with some errors. Review the run details for error codes.') + ->warning() + ->sendToDatabase($user) + ->send(); + + return; + } + + if ($run->status === InventorySyncRun::STATUS_SKIPPED) { + $reason = (string) (($run->error_codes ?? [])[0] ?? 'skipped'); + + foreach ($policyTypes as $policyType) { + $bulkOperationService->recordSkippedWithReason($bulkRun, (string) $policyType, $reason); + } + $bulkOperationService->complete($bulkRun); + + $auditLogger->log( + tenant: $tenant, + action: 'inventory.sync.skipped', + context: [ + 'metadata' => [ + 'inventory_sync_run_id' => $run->id, + 'bulk_run_id' => $bulkRun->id, + 'selection_hash' => $run->selection_hash, + 'reason' => $reason, + ], + ], + actorId: $user->id, + actorEmail: $user->email, + actorName: $user->name, + resourceType: 'inventory_sync_run', + resourceId: (string) $run->id, + ); + + Notification::make() + ->title('Inventory sync skipped') + ->body('Inventory sync could not start due to locks or concurrency limits.') + ->warning() + ->sendToDatabase($user) + ->send(); + + return; + } + + $reason = (string) (($run->error_codes ?? [])[0] ?? 'failed'); + + $missingPolicyTypes = array_values(array_diff($policyTypes, array_unique($processedPolicyTypes))); + foreach ($missingPolicyTypes as $policyType) { + $bulkOperationService->recordFailure($bulkRun, (string) $policyType, $reason); + } + + $bulkOperationService->complete($bulkRun); + + $auditLogger->log( + tenant: $tenant, + action: 'inventory.sync.failed', + context: [ + 'metadata' => [ + 'inventory_sync_run_id' => $run->id, + 'bulk_run_id' => $bulkRun->id, + 'selection_hash' => $run->selection_hash, + 'reason' => $reason, + ], + ], + actorId: $user->id, + actorEmail: $user->email, + actorName: $user->name, + status: 'failure', + resourceType: 'inventory_sync_run', + resourceId: (string) $run->id, + ); + + Notification::make() + ->title('Inventory sync failed') + ->body('Inventory sync finished with errors.') + ->danger() + ->sendToDatabase($user) + ->send(); + } +} diff --git a/app/Models/InventorySyncRun.php b/app/Models/InventorySyncRun.php index 9250153..7193444 100644 --- a/app/Models/InventorySyncRun.php +++ b/app/Models/InventorySyncRun.php @@ -33,11 +33,18 @@ class InventorySyncRun extends Model 'finished_at' => 'datetime', ]; + public const STATUS_PENDING = 'pending'; + public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); } + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + public function scopeCompleted(Builder $query): Builder { return $query diff --git a/app/Services/Inventory/InventorySyncService.php b/app/Services/Inventory/InventorySyncService.php index 59c2509..a43e693 100644 --- a/app/Services/Inventory/InventorySyncService.php +++ b/app/Services/Inventory/InventorySyncService.php @@ -5,6 +5,7 @@ use App\Models\InventoryItem; use App\Models\InventorySyncRun; use App\Models\Tenant; +use App\Models\User; use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; @@ -30,9 +31,9 @@ public function __construct( */ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncRun { - $normalizedSelection = $this->selectionHasher->normalize($selectionPayload); - $normalizedSelection['policy_types'] = $this->policyTypeResolver->filterRuntime($normalizedSelection['policy_types']); - $selectionHash = $this->selectionHasher->hash($normalizedSelection); + $computed = $this->normalizeAndHashSelection($selectionPayload); + $normalizedSelection = $computed['selection']; + $selectionHash = $computed['selection_hash']; $now = CarbonImmutable::now('UTC'); @@ -76,6 +77,7 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR $run = InventorySyncRun::query()->create([ 'tenant_id' => $tenant->getKey(), + 'user_id' => null, 'selection_hash' => $selectionHash, 'selection_payload' => $normalizedSelection, 'status' => InventorySyncRun::STATUS_RUNNING, @@ -98,10 +100,139 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR } } + /** + * @return array{policy_types: list, categories: list, include_foundations: bool, include_dependencies: bool} + */ + public function defaultSelectionPayload(): array + { + return [ + 'policy_types' => $this->policyTypeResolver->supportedPolicyTypes(), + 'categories' => [], + 'include_foundations' => true, + 'include_dependencies' => true, + ]; + } + + /** + * @param array $selectionPayload + * @return array{selection: array{policy_types: list, categories: list, include_foundations: bool, include_dependencies: bool}, selection_hash: string} + */ + public function normalizeAndHashSelection(array $selectionPayload): array + { + $normalizedSelection = $this->selectionHasher->normalize($selectionPayload); + $normalizedSelection['policy_types'] = $this->policyTypeResolver->filterRuntime($normalizedSelection['policy_types']); + $selectionHash = $this->selectionHasher->hash($normalizedSelection); + + return [ + 'selection' => $normalizedSelection, + 'selection_hash' => $selectionHash, + ]; + } + + /** + * Creates a pending run record attributed to the initiating user so the run remains observable even if queue workers are down. + * + * @param array $selectionPayload + */ + public function createPendingRunForUser(Tenant $tenant, User $user, array $selectionPayload): InventorySyncRun + { + $computed = $this->normalizeAndHashSelection($selectionPayload); + + return InventorySyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'selection_hash' => $computed['selection_hash'], + 'selection_payload' => $computed['selection'], + 'status' => InventorySyncRun::STATUS_PENDING, + 'had_errors' => false, + 'error_codes' => [], + 'error_context' => null, + 'started_at' => null, + 'finished_at' => null, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'errors_count' => 0, + ]); + } + + /** + * Executes an existing pending run under locks/concurrency, updating that run to running/skipped/terminal. + */ + /** + * @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed + */ + public function executePendingRun(InventorySyncRun $run, Tenant $tenant, ?callable $onPolicyTypeProcessed = null): InventorySyncRun + { + $computed = $this->normalizeAndHashSelection($run->selection_payload ?? []); + $normalizedSelection = $computed['selection']; + $selectionHash = $computed['selection_hash']; + + $now = CarbonImmutable::now('UTC'); + + $run->update([ + 'tenant_id' => $tenant->getKey(), + 'selection_hash' => $selectionHash, + 'selection_payload' => $normalizedSelection, + 'status' => InventorySyncRun::STATUS_RUNNING, + 'had_errors' => false, + 'error_codes' => [], + 'error_context' => null, + 'started_at' => $now, + 'finished_at' => null, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'errors_count' => 0, + ]); + + $globalSlot = $this->concurrencyLimiter->acquireGlobalSlot(); + if (! $globalSlot instanceof Lock) { + return $this->markExistingRunSkipped( + run: $run, + now: $now, + errorCode: 'concurrency_limit_global', + ); + } + + $tenantSlot = $this->concurrencyLimiter->acquireTenantSlot((int) $tenant->id); + if (! $tenantSlot instanceof Lock) { + $globalSlot->release(); + + return $this->markExistingRunSkipped( + run: $run, + now: $now, + errorCode: 'concurrency_limit_tenant', + ); + } + + $selectionLock = Cache::lock($this->selectionLockKey($tenant, $selectionHash), 900); + if (! $selectionLock->get()) { + $tenantSlot->release(); + $globalSlot->release(); + + return $this->markExistingRunSkipped( + run: $run, + now: $now, + errorCode: 'lock_contended', + ); + } + + try { + return $this->executeRun($run, $tenant, $normalizedSelection, $onPolicyTypeProcessed); + } finally { + $selectionLock->release(); + $tenantSlot->release(); + $globalSlot->release(); + } + } + /** * @param array{policy_types: list, categories: list, include_foundations: bool, include_dependencies: bool} $normalizedSelection */ - private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normalizedSelection): InventorySyncRun + /** + * @param array{policy_types: list, categories: list, include_foundations: bool, include_dependencies: bool} $normalizedSelection + * @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed + */ + private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): InventorySyncRun { $observed = 0; $upserted = 0; @@ -116,6 +247,11 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal $typeConfig = $typesConfig[$policyType] ?? null; if (! is_array($typeConfig)) { + $hadErrors = true; + $errors++; + $errorCodes[] = 'unsupported_type'; + $onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, 'unsupported_type'); + continue; } @@ -130,7 +266,9 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal if ($response->failed()) { $hadErrors = true; $errors++; - $errorCodes[] = $this->mapGraphFailureToErrorCode($response); + $errorCode = $this->mapGraphFailureToErrorCode($response); + $errorCodes[] = $errorCode; + $onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, $errorCode); continue; } @@ -187,6 +325,8 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal $upserted++; } + + $onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null); } $status = $hadErrors ? InventorySyncRun::STATUS_PARTIAL : InventorySyncRun::STATUS_SUCCESS; @@ -304,6 +444,7 @@ private function createSkippedRun( ): InventorySyncRun { return InventorySyncRun::query()->create([ 'tenant_id' => $tenant->getKey(), + 'user_id' => null, 'selection_hash' => $selectionHash, 'selection_payload' => $selectionPayload, 'status' => InventorySyncRun::STATUS_SKIPPED, @@ -318,6 +459,23 @@ private function createSkippedRun( ]); } + private function markExistingRunSkipped(InventorySyncRun $run, CarbonImmutable $now, string $errorCode): InventorySyncRun + { + $run->update([ + 'status' => InventorySyncRun::STATUS_SKIPPED, + 'had_errors' => true, + 'error_codes' => [$errorCode], + 'error_context' => null, + 'started_at' => $run->started_at ?? $now, + 'finished_at' => $now, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'errors_count' => 0, + ]); + + return $run->refresh(); + } + private function mapGraphFailureToErrorCode(GraphResponse $response): string { $status = (int) ($response->status ?? 0); diff --git a/database/migrations/2026_01_09_010348_add_user_id_to_inventory_sync_runs_table.php b/database/migrations/2026_01_09_010348_add_user_id_to_inventory_sync_runs_table.php new file mode 100644 index 0000000..38e4a69 --- /dev/null +++ b/database/migrations/2026_01_09_010348_add_user_id_to_inventory_sync_runs_table.php @@ -0,0 +1,35 @@ +foreignId('user_id') + ->nullable() + ->constrained() + ->nullOnDelete() + ->after('tenant_id'); + + $table->index(['tenant_id', 'user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventory_sync_runs', function (Blueprint $table) { + $table->dropIndex(['tenant_id', 'user_id']); + $table->dropConstrainedForeignId('user_id'); + }); + } +}; diff --git a/specs/046-inventory-sync-button/contracts/internal-actions.md b/specs/046-inventory-sync-button/contracts/internal-actions.md new file mode 100644 index 0000000..4230ca3 --- /dev/null +++ b/specs/046-inventory-sync-button/contracts/internal-actions.md @@ -0,0 +1,33 @@ +# Contracts: Internal UI Actions (046) + +This feature does not introduce a public HTTP API. It adds an internal Filament UI action that dispatches a queued job. + +## Action: Start Inventory Sync + +**Surface**: Filament Page action (Inventory area) + +**Inputs** +- `tenant_id`: from `Tenant::current()` (required) +- `selection_payload` (required; default “full inventory”): + - `policy_types`: `PolicyTypeResolver::supportedPolicyTypes()` + - `categories`: `[]` + - `include_foundations`: `true` + - `include_dependencies`: `true` +- `user_id`: `auth()->id()` (required) + +**Side effects** +- Creates `BulkOperationRun` (resource `inventory`, action `sync`, total_items `1`) so the bottom-right progress widget displays the operation. +- Dispatches `RunInventorySyncJob` with the needed identifiers. +- Creates an `InventorySyncRun` with status `running` once the job begins execution. +- Writes audit log entries: + - `inventory.sync.dispatched` + - terminal: `inventory.sync.completed|inventory.sync.failed|inventory.sync.skipped` +- Sends Filament database notifications to the initiating user: + - started + - completed/failed/skipped + +**Failure modes** +- Tenant not selected: action should be unavailable or no-op. +- Unauthorized user: action hidden/denied. +- Concurrency/lock gating: no overlapping run; user receives an informational message; run may be marked `skipped` with an error code. +- Queue not running: operation remains queued; user can observe this via progress widget + run list. diff --git a/specs/046-inventory-sync-button/data-model.md b/specs/046-inventory-sync-button/data-model.md new file mode 100644 index 0000000..1daf7e6 --- /dev/null +++ b/specs/046-inventory-sync-button/data-model.md @@ -0,0 +1,71 @@ +# Phase 1 Design: Data Model (046) + +## Entities + +### InventorySyncRun +Existing table: `inventory_sync_runs` + +**Purpose**: Observable record of one inventory sync execution for a tenant + selection. + +**Existing fields (selected)** +- `id` +- `tenant_id` (FK) +- `selection_hash` (sha256) +- `selection_payload` (jsonb) +- `status` (`running|success|partial|failed|skipped`) +- `had_errors` (bool) +- `error_codes` (jsonb) +- `error_context` (jsonb) +- `started_at`, `finished_at` +- `items_observed_count`, `items_upserted_count`, `errors_count` + +**Planned additions (to satisfy FR-008)** +- `user_id` (nullable FK to `users.id`) + - Meaning: initiator of the run (UI-triggered). `null` allowed for system/scheduled runs. + +**Relationships** +- `InventorySyncRun belongsTo Tenant` +- `InventorySyncRun belongsTo User` (new, nullable) + +**Validation rules (selection payload)** +- `policy_types`: list of strings; filtered through `PolicyTypeResolver::filterRuntime(...)` +- `categories`: list of strings (optional; currently not used for the UI-triggered “full inventory” run) +- `include_foundations`: boolean +- `include_dependencies`: boolean + +### BulkOperationRun +Existing table: `bulk_operation_runs` + +**Purpose**: Generic progress tracking for background operations. Used by the bottom-right progress widget. + +**Relevant fields** +- `tenant_id`, `user_id` +- `resource`, `action` (used for display) +- `status` (`pending|running|completed|completed_with_errors|failed|aborted`) +- Counters: `total_items`, `processed_items`, `succeeded`, `failed`, `skipped` +- `audit_log_id` (FK) + +**Usage for Inventory Sync** +- `resource = 'inventory'`, `action = 'sync'` +- `total_items = 1` +- `item_ids = [selection_hash]` (or `[inventory_sync_run_id]` after creation) + +### AuditLog +Existing table: `audit_logs` + +**Purpose**: Immutable audit trail with actor identity snapshot. + +**Usage for Inventory Sync** +- Emit `inventory.sync.dispatched` and a terminal event like `inventory.sync.completed` / `inventory.sync.failed`. +- `resource_type = 'inventory_sync_run'`, `resource_id = (string) $run->id` +- Actor fields filled from authenticated user. + +## State transitions + +### InventorySyncRun +- `running` → `success|partial|failed` +- `running` → `skipped` (lock/concurrency) + +### BulkOperationRun +- `pending` → `running` → `completed|completed_with_errors|failed|aborted` + diff --git a/specs/046-inventory-sync-button/plan.md b/specs/046-inventory-sync-button/plan.md new file mode 100644 index 0000000..e59e79a --- /dev/null +++ b/specs/046-inventory-sync-button/plan.md @@ -0,0 +1,140 @@ +# Implementation Plan: Inventory Sync Button (046) + +**Branch**: `046-inventory-sync-button` | **Date**: 2026-01-08 | **Spec**: `specs/046-inventory-sync-button/spec.md` +**Input**: Feature specification from `specs/046-inventory-sync-button/spec.md` + +## Summary + +Add a “Run Inventory Sync” action in the Inventory area that dispatches a queued job, records a new `InventorySyncRun` attributed to the initiating user, and provides visibility via: + +- Filament database notifications +- Existing bottom-right progress widget (powered by `BulkOperationRun` + `BulkOperationProgress`) + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3 +**Storage**: PostgreSQL (JSONB used for run payload + error context) +**Testing**: Pest v4 (Laravel test runner) +**Target Platform**: Containerized (Laravel Sail locally), deployed via Dokploy +**Project Type**: Web application (Laravel + Filament admin panel) +**Performance Goals**: UI action should enqueue quickly (<2s perceived) and never block on Graph calls +**Constraints**: Tenant-isolated, idempotent start semantics, observable background execution, queue workers may be down +**Scale/Scope**: Single-tenant interactive action; one run per tenant/selection at a time (locks/concurrency) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: This feature refreshes Inventory (“last observed”) and does not touch snapshots/backups. +- Read/write separation: No Graph writes. Only reads + local DB writes (run records + inventory items). +- Graph contract path: Inventory sync already uses `GraphClientInterface`; this feature does not add new Graph endpoints. +- Deterministic capabilities: Uses `PolicyTypeResolver` + `config('tenantpilot.supported_policy_types')` to derive default selection. +- Tenant isolation: Uses `Tenant::current()` for all run creation and progress tracking. +- Automation: Uses existing inventory locks/concurrency; runs are observable via `InventorySyncRun` and `BulkOperationRun`. +- Data minimization: Inventory sync continues to store metadata + sanitized meta JSON; no secrets in notifications/logs. + +## Project Structure + +### Documentation (this feature) + +```text +specs/046-inventory-sync-button/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── internal-actions.md +└── tasks.md # Phase 2 output (/speckit.tasks) - not created here +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/ +│ └── Pages/ +│ └── InventoryLanding.php # add header action “Run Inventory Sync” +├── Jobs/ +│ └── RunInventorySyncJob.php # new queued job +├── Models/ +│ ├── InventorySyncRun.php # add user relationship (nullable) +│ └── BulkOperationRun.php # reused by progress widget +├── Services/ +│ ├── BulkOperationService.php # reused for progress + audit +│ ├── Intune/ +│ │ └── AuditLogger.php # reused for inventory audit trail +│ └── Inventory/ +│ └── InventorySyncService.php # add entrypoint to run sync attributed to user + +database/migrations/ +└── xxxx_xx_xx_xxxxxx_add_user_id_to_inventory_sync_runs_table.php + +resources/views/ +└── filament/pages/inventory-landing.blade.php # may need to show action if not already in header + +tests/Feature/ +└── Inventory/InventorySyncButtonTest.php # new Pest feature test +``` + +**Structure Decision**: Web application, implemented in Filament page actions + Laravel queued job. + +## Phase 0 Output (Research) + +Completed in `specs/046-inventory-sync-button/research.md`. + +Key decisions used in this plan: +- Reuse existing bottom-right progress widget by creating a `BulkOperationRun` (`resource=inventory`, `action=sync`). +- Authorize using existing tenant role capability: `User::canSyncTenant(Tenant::current())`. +- Default selection = “full inventory” (all supported policy types, foundations + dependencies enabled). + +## Phase 1 Output (Design) + +Completed in: +- `specs/046-inventory-sync-button/data-model.md` +- `specs/046-inventory-sync-button/contracts/internal-actions.md` + +## Phase 2 Plan (Implementation Outline) + +### 1) Data model: store initiator on run record + +- Add nullable `user_id` foreign key to `inventory_sync_runs`. +- Add `InventorySyncRun::user()` relationship. +- Backfill is not required (existing rows can remain `null`). + +### 2) Job orchestration: queued execution + progress tracking + +- Add `RunInventorySyncJob` that: + - Loads the tenant + user + `BulkOperationRun`. + - Marks the `BulkOperationRun` as running (`BulkOperationService::start`). + - Runs inventory sync via `InventorySyncService` in a way that sets `InventorySyncRun.user_id`. + - Records bulk progress as a single-item operation: + - success: `recordSuccess` + `complete` + - failure/skip: `recordFailure`/`recordSkippedWithReason` + `complete` (or `fail` for hard failures) + - Sends Filament database notifications to the initiating user on completion/failure. + +### 3) UI: Inventory “Run Inventory Sync” action + +- Add a header action on `InventoryLanding`: + - Visible/authorized only if `auth()->user()` is a `User` and `canSyncTenant(Tenant::current())`. + - On click: + - Compute default selection payload. + - Pre-flight check: if a matching `InventorySyncRun` is currently `running` (same tenant + selection hash), show an informational notification and do not dispatch. + - Create a `BulkOperationRun` via `BulkOperationService::createRun(...)` with `total_items=1`. + - Send a “started” Filament DB notification with the “check bottom right progress bar” copy. + - Dispatch `RunInventorySyncJob` with identifiers. + +### 4) Audit trail + +- Use `AuditLogger` to emit at minimum: + - `inventory.sync.dispatched` + - terminal event keyed by outcome: `inventory.sync.completed|inventory.sync.failed|inventory.sync.skipped` + +### 5) Tests (Pest) + +- New feature test covering: + - authorized user can dispatch a sync from the Inventory page and it creates a `BulkOperationRun`. + - `RunInventorySyncJob` sets `InventorySyncRun.user_id`. + - unauthorized user cannot see/execute the action. + - concurrency case: when a run is already `running`, a second dispatch is prevented. diff --git a/specs/046-inventory-sync-button/quickstart.md b/specs/046-inventory-sync-button/quickstart.md new file mode 100644 index 0000000..a665c1d --- /dev/null +++ b/specs/046-inventory-sync-button/quickstart.md @@ -0,0 +1,35 @@ +# Quickstart: Inventory Sync Button (046) + +## Goal +Start an Inventory Sync for the currently selected tenant from the Filament UI, observe progress in: +- Filament database notifications panel +- Bottom-right progress widget (BulkOperationProgress) + +## Local prerequisites +- Sail running +- Queue worker running (required for the new queued sync job) + +## Run locally + +1. Start the stack: + - `./vendor/bin/sail up -d` + +2. Run a queue worker: + - `./vendor/bin/sail artisan queue:work --tries=1` + +3. Open the admin panel and pick a tenant: + - Visit `/admin` and select a tenant context. + +4. Navigate to Inventory: + - Use the left navigation “Inventory”. + +5. Click “Run Inventory Sync”: + - Select which policy types to include (or click “Select all”). + - You should see: + - A database notification (started) + - A bottom-right progress widget entry for `Sync inventory` + - A new Inventory Sync Run record in the Inventory Sync Runs list + +## Notes +- If the queue worker is not running, the progress widget will remain in “Queued…” and the run will not advance. +- Concurrency/lock gating may skip the run; the UI should show a clear informational message and a run record will reflect the skip reason. diff --git a/specs/046-inventory-sync-button/research.md b/specs/046-inventory-sync-button/research.md new file mode 100644 index 0000000..f9ca3eb --- /dev/null +++ b/specs/046-inventory-sync-button/research.md @@ -0,0 +1,63 @@ +# Phase 0 Research: Inventory Sync Button (046) + +**Date**: 2026-01-09 + +## Findings + +### Existing patterns to reuse + +#### DB-backed notifications +- Filament DB notifications are already used in multiple places. +- Example: Policy Sync action calls `Filament\Notifications\Notification::make()->sendToDatabase(auth()->user())->send()`. + +#### Bottom-right progress widget +- The bottom-right progress widget is implemented by `App\Livewire\BulkOperationProgress` and renders `resources/views/livewire/bulk-operation-progress.blade.php`. +- It polls `BulkOperationRun` filtered by `tenant_id = Tenant::current()->id` and `user_id = auth()->id()`. +- It is injected globally into Filament via a render hook in `App\Providers\Filament\AdminPanelProvider`. + +#### Inventory Sync run records +- Inventory sync runs are already persisted in `inventory_sync_runs` with counts and status. +- Current `InventorySyncService::syncNow(...)` runs inline and uses locks/concurrency to create/update `InventorySyncRun`. + +#### Authorization +- The app already uses tenant-role based authorization for sync operations (e.g. `User::canSyncTenant($tenant)` in `TenantResource`). + +#### Inventory selection payload +- Inventory Sync requires a selection payload with shape: `{policy_types: list, categories: list, include_foundations: bool, include_dependencies: bool}`. +- There is no existing UI picker for inventory selection. + +## Decisions + +### Decision: Start Inventory Sync as a queued job +- **Chosen**: Dispatch an Inventory Sync job from the UI action. +- **Rationale**: Aligns with existing background operation UX and avoids blocking Livewire requests. +- **Alternatives considered**: + - Run inline (current `syncNow`) — rejected due to UX (slow request) and mismatch with existing “progress widget” expectations. + +### Decision: Use DB notifications + progress widget UX consistent with Policy/Bulk operations +- **Chosen**: Create a `BulkOperationRun` (resource `inventory`, action `sync`) so the existing bottom-right widget shows progress; also send DB notifications at start and completion/failure. +- **Rationale**: Matches established UX language and avoids inventing new UI surfaces. +- **Alternatives considered**: + - Only show toast notifications — rejected; user explicitly requires DB notification panel + progress widget. + +### Decision: Authorize via tenant role sync permission +- **Chosen**: Gate the UI action using `auth()->user()->canSyncTenant(Tenant::current())`. +- **Rationale**: Aligns with existing “sync” authorization patterns already used for tenant/policy operations. +- **Alternatives considered**: + - Introduce new permission strings/roles — rejected for MVP; adds RBAC surface area. + +### Decision: Default selection = “full inventory” +- **Chosen**: Dispatch inventory sync with policy types set to `PolicyTypeResolver::supportedPolicyTypes()`, empty categories, and `include_foundations=true`, `include_dependencies=true`. +- **Rationale**: Simplest interpretation of “Run Inventory Sync” without inventing a new picker UX. +- **Alternatives considered**: + - Reuse backup policy picker UI — rejected; different domain (backup selection), more UX than requested. + +### Decision: Attribute initiator on run record and audit trail +- **Chosen**: Store initiator identity on `InventorySyncRun` and also emit an audit record. +- **Rationale**: Improves traceability and aligns with constitution principle “Automation must be Idempotent & Observable”. +- **Alternatives considered**: + - Audit log only — rejected (you chose C). + +## Open Questions (for Phase 1 design) + +- None remaining for planning; implementation will add a dedicated queued job. diff --git a/specs/046-inventory-sync-button/spec.md b/specs/046-inventory-sync-button/spec.md index 75d603b..0ee2609 100644 --- a/specs/046-inventory-sync-button/spec.md +++ b/specs/046-inventory-sync-button/spec.md @@ -33,6 +33,7 @@ ### User Story 1 - Run Inventory Sync from UI (Priority: P1) 1. **Given** I am viewing Inventory for a tenant, **When** I click "Run Inventory Sync", **Then** a new Inventory Sync Run is created for that tenant and work is started asynchronously. 2. **Given** a sync run is started, **When** I view in-app notifications and the progress widget, **Then** I can see the sync progress and final outcome. 3. **Given** a sync run finishes (success/partial/failed), **When** I open the Inventory Sync Runs list, **Then** the run is visible with its terminal status and error summary (if any). +4. **Given** I am viewing Inventory for tenant A, **When** a request/payload/state attempts to start a sync for tenant B, **Then** the request is rejected (403/AccessDenied), I see “Not allowed”, and no run/job is created for tenant B. --- @@ -68,16 +69,12 @@ ### User Story 3 - Permissioned access (Priority: P3) ### Edge Cases - - -- What happens when a sync is already running for the same tenant and selection? -- What happens when tenant credentials are missing/invalid and the sync fails early? -- What happens when the user clicks the button multiple times quickly? -- What happens when a sync is started but the UI is closed (should still finish and be observable later)? -- What happens when queue workers are down and the sync does not progress? +- If a sync is already `running` for the same tenant + selection, the UI MUST not dispatch another run and MUST show an informational message. +- If tenant credentials are missing/invalid, the run MUST end in a terminal failure state and the initiating user MUST be notified. +- If the user clicks multiple times quickly, only one run MUST be dispatched; subsequent attempts MUST be blocked with a clear message. +- If the UI is closed after dispatch, the run MUST still finish and remain observable later via runs list + notifications. +- If queue workers are down, the run MUST remain observable as “queued/pending” and the UI MUST not pretend completion. +- If a request/payload/state attempts cross-tenant initiation, the system MUST reject the attempt server-side (403/AccessDenied) and MUST NOT create any run/job for another tenant. ## Requirements *(mandatory)* @@ -92,7 +89,7 @@ ## Requirements *(mandatory)* ### Functional Requirements - **FR-001**: System MUST provide an admin UI action to start an Inventory Sync for the currently selected tenant. -- **FR-002**: System MUST scope the sync to the currently selected tenant and MUST NOT allow cross-tenant initiation. +- **FR-002**: System MUST run Inventory Sync exclusively in the context of the current tenant (`Tenant::current()`). If any request/payload/state attempts to target a different tenant (e.g. provides `tenant_id` that does not match `Tenant::current()->id`), the system MUST reject the attempt server-side with 403/AccessDenied, MUST show a user-visible “Not allowed” message, and MUST NOT create any `InventorySyncRun`, `BulkOperationRun`, or queued job for the foreign tenant. - **FR-003**: System MUST create an observable Inventory Sync Run record when a sync is started. - **FR-004**: System MUST start the Inventory Sync asynchronously (queued/background execution), not inline in the request. - **FR-005**: System MUST enforce existing concurrency/lock rules and MUST NOT start overlapping runs for the same tenant/selection. @@ -125,11 +122,6 @@ ### Key Entities *(include if feature involves data)* ## Success Criteria *(mandatory)* - - ### Measurable Outcomes - **SC-001**: An authorized admin can start an Inventory Sync from the UI in under 10 seconds (time to find + click action). diff --git a/specs/046-inventory-sync-button/tasks.md b/specs/046-inventory-sync-button/tasks.md new file mode 100644 index 0000000..556051d --- /dev/null +++ b/specs/046-inventory-sync-button/tasks.md @@ -0,0 +1,170 @@ +--- + +description: "Task list for Inventory Sync Button (046)" + +--- + +# Tasks: Inventory Sync Button (046) + +**Input**: Design documents from `/specs/046-inventory-sync-button/` +**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/ + +**Tests**: REQUIRED (Pest) — this feature changes runtime behavior. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `- [ ] T### [P?] [Story] Description with file path` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Ensure feature docs and baseline scaffolding are in place. + +- [ ] T001 Verify feature docs exist and are current in specs/046-inventory-sync-button/{spec.md,plan.md,research.md,data-model.md,quickstart.md,contracts/internal-actions.md} +- [X] T001 Verify feature docs exist and are current in specs/046-inventory-sync-button/{spec.md,plan.md,research.md,data-model.md,quickstart.md,contracts/internal-actions.md} +- [X] T002 [P] Locate existing Inventory landing UI and runs list pages in app/Filament/Pages/InventoryLanding.php and app/Filament/Resources/InventorySyncRunResource/** + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story work can be finished end-to-end. + +- [X] T003 Add nullable initiator FK to inventory sync runs in database/migrations/xxxx_xx_xx_xxxxxx_add_user_id_to_inventory_sync_runs_table.php +- [X] T004 [P] Add `user()` relationship + casts/guarding as needed in app/Models/InventorySyncRun.php +- [X] T005 [P] Add `use Illuminate\Database\Eloquent\Relations\BelongsTo;` + `user(): BelongsTo` in app/Models/InventorySyncRun.php +- [X] T006 Add queued job skeleton for inventory sync in app/Jobs/RunInventorySyncJob.php +- [X] T007 [P] Add default inventory selection builder (full inventory) and deterministic selection_hash helper as methods on app/Services/Inventory/InventorySyncService.php (no new factory/hasher classes) +- [X] T008 Add service entrypoint that attributes initiator on run record in app/Services/Inventory/InventorySyncService.php + +**Checkpoint**: DB schema + job/service primitives exist. + +--- + +## Phase 3: User Story 1 — Run Inventory Sync from UI (Priority: P1) 🎯 MVP + +**Goal**: Provide a “Run Inventory Sync” action for the current tenant that dispatches work asynchronously and is observable. + +**Independent Test**: Clicking the action creates a `BulkOperationRun` and an `InventorySyncRun`, then job completes and updates both + DB notifications. + +### Tests for User Story 1 (REQUIRED) + +- [X] T010 [P] [US1] Add feature test covering action dispatch + run creation AND cross-tenant rejection (403 + “Not allowed” + no run/job created) in tests/Feature/Inventory/InventorySyncButtonTest.php +- [X] T011 [P] [US1] Add feature test covering queued job success updates bulk run + inventory run in tests/Feature/Inventory/RunInventorySyncJobTest.php + +### Implementation for User Story 1 + +- [X] T012 [US1] Add “Run Inventory Sync” header action to app/Filament/Pages/InventoryLanding.php +- [X] T013 [US1] If needed, surface the action in resources/views/filament/pages/inventory-landing.blade.php (only if header actions aren’t visible) +- [X] T014 [US1] On click, build default selection via app/Services/Inventory/InventorySyncService.php +- [X] T015 [US1] On click, create BulkOperationRun (resource `inventory`, action `sync`, total_items=1) via app/Services/BulkOperationService.php (inline from the action handler) +- [X] T016 [US1] On click, send “started” Filament DB notification in app/Filament/Pages/InventoryLanding.php +- [X] T017 [US1] On click, dispatch app/Jobs/RunInventorySyncJob.php with tenant_id, user_id, bulk_run_id, selection_payload +- [X] T018 [US1] Implement app/Jobs/RunInventorySyncJob.php to start bulk run + call InventorySyncService entrypoint +- [X] T019 [US1] In job, map InventorySyncRun terminal outcome → BulkOperationService counters/status in app/Jobs/RunInventorySyncJob.php +- [X] T020 [US1] In job, send completion/failure DB notification to the initiating user in app/Jobs/RunInventorySyncJob.php +- [X] T021 [US1] Emit audit events for dispatched + completed/failed/skipped using app/Services/Intune/AuditLogger.php from app/Jobs/RunInventorySyncJob.php + +**Checkpoint**: US1 works end-to-end with queue worker running. + +--- + +## Phase 4: User Story 2 — Safe feedback when sync cannot start (Priority: P2) + +**Goal**: Prevent duplicate/overlapping runs and provide clear feedback when sync can’t start. + +**Independent Test**: With a `running` InventorySyncRun for the same tenant+selection, the action does not dispatch a new job and shows an informational message. + +### Tests for User Story 2 (REQUIRED) + +- [X] T022 [P] [US2] Add feature test for “already running” preflight blocking in tests/Feature/Inventory/InventorySyncButtonTest.php +- [X] T023 [P] [US2] Add feature test for lock/concurrency skip outcome mapping in tests/Feature/Inventory/RunInventorySyncJobTest.php + +### Implementation for User Story 2 + +- [X] T024 [US2] Add preflight check for existing running selection_hash in app/Filament/Pages/InventoryLanding.php +- [X] T025 [US2] Compute selection_hash using app/Services/Inventory/InventorySyncService.php (same logic used for run creation) and use it for the preflight check in app/Filament/Pages/InventoryLanding.php +- [X] T026 [US2] Add clear Filament notification message when blocked due to running/locks in app/Filament/Pages/InventoryLanding.php +- [X] T027 [US2] Ensure RunInventorySyncJob handles `skipped` InventorySyncRun (concurrency/lock) and marks BulkOperationRun as completed_with_errors or completed (skipped) with reason in app/Jobs/RunInventorySyncJob.php + +**Checkpoint**: User gets clear feedback; duplicates are prevented. + +--- + +## Phase 5: User Story 3 — Permissioned access (Priority: P3) + +**Goal**: Only authorized users can start Inventory Sync. + +**Independent Test**: Unauthorized users do not see the action (or cannot execute it) and no runs are created. + +### Tests for User Story 3 (REQUIRED) + +- [X] T028 [P] [US3] Add feature test that unauthorized user cannot see/execute the action in tests/Feature/Inventory/InventorySyncButtonTest.php + +### Implementation for User Story 3 + +- [X] T029 [US3] Gate action visibility/authorization via User::canSyncTenant(Tenant::current()) in app/Filament/Pages/InventoryLanding.php +- [X] T030 [US3] Ensure server-side authorization check is enforced in action handler (not only visibility) in app/Filament/Pages/InventoryLanding.php + +**Checkpoint**: Authorization is enforced and tested. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Make delivery safe, consistent, and easy to validate. + +- [X] T031 [P] Add/adjust translation-safe, consistent notification copy (start/complete/fail) in app/Filament/Pages/InventoryLanding.php and app/Jobs/RunInventorySyncJob.php +- [X] T032 Ensure `InventorySyncRunResource` shows initiator when present (user_id) in app/Filament/Resources/InventorySyncRunResource.php +- [X] T033 [P] Run formatter on changed files: `./vendor/bin/pint --dirty` +- [X] T034 Run targeted tests: `./vendor/bin/sail artisan test --filter=InventorySync` (or specific test files) +- [X] T035 Validate quickstart steps remain accurate in specs/046-inventory-sync-button/quickstart.md +- [X] T036 Make BulkOperation progress reflect selected policy types (total_items = #types; per-type success/failure) in app/Filament/Pages/InventoryLanding.php and app/Jobs/RunInventorySyncJob.php + +--- + +## Dependencies & Execution Order + +### User Story dependency graph + +- Setup → Foundational → US1 → (US2, US3) → Polish + +Rationale: +- US1 establishes the action + job + progress/notifications baseline. +- US2 and US3 are incremental safety/guardrails on top of the same action. + +### Parallel opportunities + +- Phase 2: T004, T005, T007 can run in parallel (separate files). +- US1: T010 and T011 can be written in parallel. +- US2: T022 and T023 can be written in parallel. + +--- + +## Parallel Example: User Story 1 + +Parallelizable work for US1 (after Phase 2 is complete): + +```text +Run in parallel: +- T010 + T011 (tests) +- T012 + T018 (UI action + job implementation) + +Then: +- T015–T017 (wire dispatch + notifications) +- T019–T021 (status mapping + DB notif + audit) +``` + +--- + +## Implementation Strategy + +### MVP scope (recommended) + +- Deliver US1 only (Phases 1–3) and validate via quickstart. +- Then add US2 and US3 as hardening increments. diff --git a/tests/Feature/Inventory/InventorySyncButtonTest.php b/tests/Feature/Inventory/InventorySyncButtonTest.php new file mode 100644 index 0000000..37accaf --- /dev/null +++ b/tests/Feature/Inventory/InventorySyncButtonTest.php @@ -0,0 +1,142 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $sync = app(InventorySyncService::class); + $allTypes = $sync->defaultSelectionPayload()['policy_types']; + + Livewire::test(InventoryLanding::class) + ->callAction('run_inventory_sync', data: ['policy_types' => $allTypes]); + + Queue::assertPushed(RunInventorySyncJob::class); + + $run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first(); + expect($run)->not->toBeNull(); + expect($run->user_id)->toBe($user->id); + expect($run->status)->toBe(InventorySyncRun::STATUS_PENDING); + + $bulkRun = BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('resource', 'inventory') + ->where('action', 'sync') + ->latest('id') + ->first(); + + expect($bulkRun)->not->toBeNull(); +}); + +it('dispatches inventory sync for selected policy types', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $sync = app(InventorySyncService::class); + $allTypes = $sync->defaultSelectionPayload()['policy_types']; + $selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes))); + + Livewire::test(InventoryLanding::class) + ->mountAction('run_inventory_sync') + ->set('mountedActions.0.data.policy_types', $selectedTypes) + ->assertActionDataSet(['policy_types' => $selectedTypes]) + ->callMountedAction() + ->assertHasNoActionErrors(); + + Queue::assertPushed(RunInventorySyncJob::class); + + $run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first(); + expect($run)->not->toBeNull(); + expect($run->selection_payload['policy_types'] ?? [])->toEqualCanonicalizing($selectedTypes); +}); + +it('rejects cross-tenant initiation attempts (403) with no side effects', function () { + Queue::fake(); + + [$user, $tenantA] = createUserWithTenant(role: 'owner'); + $tenantB = Tenant::factory()->create(); + + $this->actingAs($user); + Filament::setTenant($tenantA, true); + + $sync = app(InventorySyncService::class); + $allTypes = $sync->defaultSelectionPayload()['policy_types']; + + Livewire::test(InventoryLanding::class) + ->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes]) + ->assertStatus(403); + + Queue::assertNothingPushed(); + + expect(InventorySyncRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse(); + expect(BulkOperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse(); +}); + +it('blocks dispatch when a matching run is already pending or running', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $sync = app(InventorySyncService::class); + $selectionPayload = $sync->defaultSelectionPayload(); + $computed = $sync->normalizeAndHashSelection($selectionPayload); + + InventorySyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'selection_hash' => $computed['selection_hash'], + 'selection_payload' => $computed['selection'], + 'status' => InventorySyncRun::STATUS_RUNNING, + 'had_errors' => false, + 'error_codes' => [], + 'error_context' => null, + 'started_at' => now(), + 'finished_at' => null, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'errors_count' => 0, + ]); + + Livewire::test(InventoryLanding::class) + ->callAction('run_inventory_sync', data: ['policy_types' => $computed['selection']['policy_types']]); + + Queue::assertNothingPushed(); + expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->count())->toBe(1); + expect(BulkOperationRun::query()->where('tenant_id', $tenant->id)->count())->toBe(0); +}); + +it('forbids unauthorized users from starting inventory sync', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(InventoryLanding::class) + ->assertActionHidden('run_inventory_sync'); + + Queue::assertNothingPushed(); + expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Inventory/RunInventorySyncJobTest.php b/tests/Feature/Inventory/RunInventorySyncJobTest.php new file mode 100644 index 0000000..d560269 --- /dev/null +++ b/tests/Feature/Inventory/RunInventorySyncJobTest.php @@ -0,0 +1,117 @@ +mock(GraphClientInterface::class, function (MockInterface $mock) { + $mock->shouldReceive('listPolicies') + ->atLeast() + ->once() + ->andReturn(new GraphResponse(true, [], 200)); + }); + + $sync = app(InventorySyncService::class); + $selectionPayload = $sync->defaultSelectionPayload(); + $computed = $sync->normalizeAndHashSelection($selectionPayload); + $policyTypes = $computed['selection']['policy_types']; + $run = $sync->createPendingRunForUser($tenant, $user, $selectionPayload); + + $bulkRun = app(BulkOperationService::class)->createRun( + tenant: $tenant, + user: $user, + resource: 'inventory', + action: 'sync', + itemIds: $policyTypes, + totalItems: count($policyTypes), + ); + + $job = new RunInventorySyncJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + bulkRunId: (int) $bulkRun->getKey(), + inventorySyncRunId: (int) $run->getKey(), + ); + + $job->handle(app(BulkOperationService::class), $sync, app(AuditLogger::class)); + + $run->refresh(); + $bulkRun->refresh(); + + expect($run->user_id)->toBe($user->id); + expect($run->status)->toBe(InventorySyncRun::STATUS_SUCCESS); + expect($run->started_at)->not->toBeNull(); + expect($run->finished_at)->not->toBeNull(); + + expect($bulkRun->status)->toBe('completed'); + expect($bulkRun->processed_items)->toBe(count($policyTypes)); + expect($bulkRun->succeeded)->toBe(count($policyTypes)); +}); + +it('maps skipped inventory sync runs to bulk progress as skipped with reason', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $sync = app(InventorySyncService::class); + $selectionPayload = $sync->defaultSelectionPayload(); + $run = $sync->createPendingRunForUser($tenant, $user, $selectionPayload); + + $computed = $sync->normalizeAndHashSelection($selectionPayload); + $policyTypes = $computed['selection']['policy_types']; + + $bulkRun = app(BulkOperationService::class)->createRun( + tenant: $tenant, + user: $user, + resource: 'inventory', + action: 'sync', + itemIds: $policyTypes, + totalItems: count($policyTypes), + ); + + $mockSync = \Mockery::mock(InventorySyncService::class); + $mockSync + ->shouldReceive('executePendingRun') + ->once() + ->andReturnUsing(function (InventorySyncRun $inventorySyncRun) { + $inventorySyncRun->forceFill([ + 'status' => InventorySyncRun::STATUS_SKIPPED, + 'error_codes' => ['locked'], + 'selection_payload' => $inventorySyncRun->selection_payload ?? [], + 'started_at' => now(), + 'finished_at' => now(), + ])->save(); + + return $inventorySyncRun; + }); + + $job = new RunInventorySyncJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + bulkRunId: (int) $bulkRun->getKey(), + inventorySyncRunId: (int) $run->getKey(), + ); + + $job->handle(app(BulkOperationService::class), $mockSync, app(AuditLogger::class)); + + $run->refresh(); + $bulkRun->refresh(); + + expect($run->status)->toBe(InventorySyncRun::STATUS_SKIPPED); + + expect($bulkRun->status)->toBe('completed') + ->and($bulkRun->processed_items)->toBe(count($policyTypes)) + ->and($bulkRun->skipped)->toBe(count($policyTypes)) + ->and($bulkRun->succeeded)->toBe(0) + ->and($bulkRun->failed)->toBe(0); + + expect($bulkRun->failures)->toBeArray(); + expect($bulkRun->failures[0]['type'] ?? null)->toBe('skipped'); + expect($bulkRun->failures[0]['reason'] ?? null)->toBe('locked'); +});