From 0de049442634e9ff5cec7d39a0cae9ca5c75df67 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 24 Feb 2026 10:15:34 +0100 Subject: [PATCH] feat(ops-ux): enterprise start/dedup standard --- app/Filament/Pages/BaselineCompareLanding.php | 14 ++- app/Filament/Pages/DriftLanding.php | 7 +- .../ManagedTenantOnboardingWizard.php | 66 +++++++--- .../Resources/BackupScheduleResource.php | 4 +- .../BackupItemsRelationManager.php | 15 +-- .../Pages/ViewBaselineProfile.php | 37 +++++- .../Pages/ListEntraGroups.php | 16 +-- .../Pages/ListInventoryItems.php | 5 +- app/Filament/Resources/PolicyResource.php | 65 ++++------ .../PolicyResource/Pages/ListPolicies.php | 7 +- .../Resources/PolicyVersionResource.php | 28 ----- .../Resources/ProviderConnectionResource.php | 50 ++++---- .../Pages/EditProviderConnection.php | 44 +++---- app/Filament/Resources/RestoreRunResource.php | 92 +++++++++++--- app/Filament/Resources/TenantResource.php | 28 ++--- .../TenantResource/Pages/ViewTenant.php | 30 ++--- .../Tenant/AdminRolesSummaryWidget.php | 60 +++++++-- .../Widgets/Tenant/TenantReviewPackCard.php | 56 +++++++-- .../Tenant/TenantVerificationReport.php | 17 +-- app/Jobs/AddPoliciesToBackupSetJob.php | 69 ----------- app/Jobs/ApplyBackupScheduleRetentionJob.php | 15 +-- app/Jobs/BulkPolicyExportJob.php | 83 ------------- app/Jobs/BulkPolicyUnignoreJob.php | 43 ------- app/Jobs/BulkRestoreRunForceDeleteJob.php | 66 ---------- app/Jobs/BulkRestoreRunRestoreJob.php | 66 ---------- app/Jobs/ExecuteRestoreRunJob.php | 51 -------- app/Jobs/RemovePoliciesFromBackupSetJob.php | 103 ---------------- app/Jobs/RunBackupScheduleJob.php | 115 ------------------ app/Livewire/BackupSetPolicyPickerTable.php | 5 +- .../RunStatusChangedNotification.php | 112 ----------------- .../Inventory/InventorySyncService.php | 19 +-- app/Services/OperationRunService.php | 4 +- app/Support/OpsUx/OperationUxPresenter.php | 14 +++ specs/055-ops-ux-rollout/plan.md | 2 +- specs/110-ops-ux-enforcement/plan.md | 18 +-- specs/110-ops-ux-enforcement/quickstart.md | 15 +-- specs/110-ops-ux-enforcement/spec.md | 28 +++-- specs/110-ops-ux-enforcement/tasks.md | 82 ++++++++----- .../RunNowRetryActionsTest.php | 67 ++-------- .../RemovePoliciesJobNotificationTest.php | 6 +- .../BackupSetPolicyPickerTableTest.php | 8 ++ ...BaselineCompareLandingStartSurfaceTest.php | 55 +++++++++ .../Filament/CreateCtaPlacementTest.php | 6 +- .../OperationRunNotificationTest.php | 4 +- .../DirectStatusTransitionGuardTest.php | 66 ++++++++++ .../JobDbNotificationGuardTest.php | 71 +++++++++++ .../LegacyNotificationGuardTest.php | 54 ++++++++ .../OpsUx/NoQueuedDbNotificationsTest.php | 4 +- .../OpsUx/NotificationViewRunLinkTest.php | 22 ++-- tests/Feature/OpsUx/QueuedToastCopyTest.php | 7 ++ ...ackupRetentionTerminalNotificationTest.php | 75 ++++++++++++ .../BackupScheduleRunNotificationTest.php | 113 +++++++++++++++++ .../Regression/BulkJobCircuitBreakerTest.php | 55 +++++++++ .../InventorySyncTerminalNotificationTest.php | 77 ++++++++++++ .../RestoreRunTerminalNotificationTest.php | 87 +++++++++++++ .../ReviewPack/ReviewPackGenerationTest.php | 4 +- tests/Support/OpsUx/SourceFileScanner.php | 103 ++++++++++++++++ 57 files changed, 1305 insertions(+), 1130 deletions(-) delete mode 100644 app/Notifications/RunStatusChangedNotification.php create mode 100644 tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php create mode 100644 tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php create mode 100644 tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php create mode 100644 tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php create mode 100644 tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php create mode 100644 tests/Feature/OpsUx/Regression/BackupScheduleRunNotificationTest.php create mode 100644 tests/Feature/OpsUx/Regression/BulkJobCircuitBreakerTest.php create mode 100644 tests/Feature/OpsUx/Regression/InventorySyncTerminalNotificationTest.php create mode 100644 tests/Feature/OpsUx/Regression/RestoreRunTerminalNotificationTest.php create mode 100644 tests/Support/OpsUx/SourceFileScanner.php diff --git a/app/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php index 7bde82e..fb1d8cb 100644 --- a/app/Filament/Pages/BaselineCompareLanding.php +++ b/app/Filament/Pages/BaselineCompareLanding.php @@ -14,6 +14,8 @@ use App\Services\Baselines\BaselineCompareService; use App\Support\Auth\Capabilities; use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; @@ -243,10 +245,14 @@ private function compareNowAction(): Action $this->state = 'comparing'; - Notification::make() - ->title('Baseline comparison started') - ->body('A background job will compute drift against the baseline snapshot.') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare') + ->actions($run instanceof OperationRun ? [ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($run, $tenant)), + ] : []) ->send(); }); } diff --git a/app/Filament/Pages/DriftLanding.php b/app/Filament/Pages/DriftLanding.php index ac45c7b..d0af49b 100644 --- a/app/Filament/Pages/DriftLanding.php +++ b/app/Filament/Pages/DriftLanding.php @@ -20,7 +20,6 @@ use App\Support\OpsUx\OpsUxBrowserEvents; use BackedEnum; use Filament\Actions\Action; -use Filament\Notifications\Notification; use Filament\Pages\Page; use UnitEnum; @@ -240,10 +239,8 @@ public function mount(): void $this->state = 'generating'; if (! $opRun->wasRecentlyCreated) { - Notification::make() - ->title('Drift generation already active') - ->body('This operation is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->actions([ Action::make('view_run') ->label('View run') diff --git a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index c6b62e9..b003796 100644 --- a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -33,6 +33,8 @@ use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Verification\VerificationCheckStatus; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; @@ -1444,6 +1446,8 @@ public function startVerification(): void ); if ($result->status === 'scope_busy') { + OpsUxBrowserEvents::dispatchRunEnqueued($this); + Notification::make() ->title('Another operation is already running') ->body('Please wait for the active run to finish.') @@ -1501,23 +1505,27 @@ public function startVerification(): void return; } - $notification = Notification::make() - ->title($result->status === 'deduped' ? 'Verification already running' : 'Verification started') + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + if ($result->status === 'deduped') { + OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) + ->actions([ + Action::make('view_run') + ->label('View run') + ->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())), + ]) + ->send(); + + return; + } + + OperationUxPresenter::queuedToast((string) $result->run->type) ->actions([ Action::make('view_run') ->label('View run') ->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())), - ]); - - if ($result->status === 'deduped') { - $notification - ->body('A verification run is already queued or running.') - ->warning(); - } else { - $notification->success(); - } - - $notification->send(); + ]) + ->send(); } public function refreshVerificationStatus(): void @@ -1609,7 +1617,7 @@ public function startBootstrap(array $operationTypes): void return; } - /** @var array{status: 'started', runs: array}|array{status: 'scope_busy', run: OperationRun} $result */ + /** @var array{status: 'started', runs: array, created: array}|array{status: 'scope_busy', run: OperationRun} $result */ $result = DB::transaction(function () use ($tenant, $connection, $types, $registry, $user): array { $lockedConnection = ProviderConnection::query() ->whereKey($connection->getKey()) @@ -1633,6 +1641,7 @@ public function startBootstrap(array $operationTypes): void $runsService = app(OperationRunService::class); $bootstrapRuns = []; + $bootstrapCreated = []; foreach ($types as $operationType) { $definition = $registry->get($operationType); @@ -1671,15 +1680,19 @@ public function startBootstrap(array $operationTypes): void } $bootstrapRuns[$operationType] = (int) $run->getKey(); + $bootstrapCreated[$operationType] = (bool) $run->wasRecentlyCreated; } return [ 'status' => 'started', 'runs' => $bootstrapRuns, + 'created' => $bootstrapCreated, ]; }); if ($result['status'] === 'scope_busy') { + OpsUxBrowserEvents::dispatchRunEnqueued($this); + Notification::make() ->title('Another operation is already running') ->body('Please wait for the active run to finish.') @@ -1711,10 +1724,27 @@ public function startBootstrap(array $operationTypes): void $this->onboardingSession->save(); } - Notification::make() - ->title('Bootstrap started') - ->success() - ->send(); + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + foreach ($types as $operationType) { + $runId = (int) ($bootstrapRuns[$operationType] ?? 0); + $runUrl = $runId > 0 ? $this->tenantlessOperationRunUrl($runId) : null; + $wasCreated = (bool) ($result['created'][$operationType] ?? false); + + $toast = $wasCreated + ? OperationUxPresenter::queuedToast($operationType) + : OperationUxPresenter::alreadyQueuedToast($operationType); + + if ($runUrl !== null) { + $toast->actions([ + Action::make('view_run') + ->label('View run') + ->url($runUrl), + ]); + } + + $toast->send(); + } } private function dispatchBootstrapJob( diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index a125bb3..aae9d8d 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -765,7 +765,7 @@ public static function table(Table $table): Table Action::make('view_runs') ->label('View in Operations') ->url(OperationRunLinks::index($tenant)), - ])->sendToDatabase($user); + ]); } $notification->send(); @@ -862,7 +862,7 @@ public static function table(Table $table): Table Action::make('view_runs') ->label('View in Operations') ->url(OperationRunLinks::index($tenant)), - ])->sendToDatabase($user); + ]); } $notification->send(); diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index 30a19c2..eae405b 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -18,7 +18,6 @@ use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; use Filament\Actions; -use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; @@ -105,10 +104,9 @@ public function table(Table $table): Table ); if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Removal already queued') - ->body('A matching remove operation is already queued or running.') - ->info() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') @@ -196,10 +194,9 @@ public function table(Table $table): Table ); if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Removal already queued') - ->body('A matching remove operation is already queued or running.') - ->info() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php index 1691b05..3ddd62c 100644 --- a/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php +++ b/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php @@ -11,6 +11,9 @@ use App\Models\Workspace; use App\Services\Baselines\BaselineCaptureService; use App\Support\Auth\Capabilities; +use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; use Filament\Actions\EditAction; @@ -88,10 +91,36 @@ private function captureAction(): Action return; } - Notification::make() - ->title('Capture enqueued') - ->body('Baseline snapshot capture has been started.') - ->success() + $run = $result['run'] ?? null; + + if (! $run instanceof \App\Models\OperationRun) { + Notification::make() + ->title('Cannot start capture') + ->body('Reason: missing operation run') + ->danger() + ->send(); + + return; + } + + $viewAction = Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($run, $sourceTenant)); + + if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) { + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::alreadyQueuedToast((string) $run->type) + ->actions([$viewAction]) + ->send(); + + return; + } + + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::queuedToast((string) $run->type) + ->actions([$viewAction]) ->send(); }); } diff --git a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php index 3105be5..e5b61a8 100644 --- a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php +++ b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php @@ -10,9 +10,10 @@ use App\Services\OperationRunService; use App\Support\Auth\Capabilities; use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; use Filament\Actions\Action; -use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; class ListEntraGroups extends ListRecords @@ -57,10 +58,8 @@ protected function getHeaderActions(): array ); if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { - Notification::make() - ->title('Group sync already active') - ->body('This operation is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->actions([ Action::make('view_run') ->label('View Run') @@ -80,16 +79,13 @@ protected function getHeaderActions(): array operationRun: $opRun )); - Notification::make() - ->title('Group sync started') - ->body('Sync dispatched.') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Action::make('view_run') ->label('View Run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) - ->sendToDatabase($user) ->send(); }) ) diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php index 22b1ee4..f28b127 100644 --- a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -167,10 +167,7 @@ protected function getHeaderActions(): array ); if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Inventory sync already active') - ->body('This operation is already queued or running.') - ->warning() + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->actions([ Action::make('view_run') ->label('View Run') diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index a666d1a..24c2e41 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -471,10 +471,8 @@ public static function table(Table $table): Table ); if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Policy sync already active') - ->body('This operation is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') @@ -603,7 +601,7 @@ public static function table(Table $table): Table return []; }) - ->action(function (Collection $records): void { + ->action(function (Collection $records, HasTable $livewire): void { $tenant = Tenant::current(); $user = auth()->user(); $count = $records->count(); @@ -643,19 +641,30 @@ public static function table(Table $table): Table emitQueuedNotification: false, ); - Notification::make() - ->title('Policy delete queued') + $runUrl = OperationRunLinks::view($opRun, $tenant); + + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) + ->body("Queued deletion for {$count} policies.") + ->actions([ + \Filament\Actions\Action::make('view_run') + ->label('View run') + ->url($runUrl), + ]) + ->send(); + + return; + } + + OperationUxPresenter::queuedToast((string) $opRun->type) ->body("Queued deletion for {$count} policies.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() ->actions([ \Filament\Actions\Action::make('view_run') ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), + ->url($runUrl), ]) - ->duration(8000) - ->sendToDatabase($user) ->send(); }) ->deselectRecordsAfterCompletion(), @@ -730,18 +739,6 @@ public static function table(Table $table): Table emitQueuedNotification: false, ); - 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(); - } - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OperationUxPresenter::queuedToast((string) $opRun->type) @@ -803,10 +800,8 @@ public static function table(Table $table): Table ); if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Policy sync already active') - ->body('This operation is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') @@ -900,18 +895,6 @@ public static function table(Table $table): Table emitQueuedNotification: false, ); - 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(); - } - OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') diff --git a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php index 7d8edc5..be30a43 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php +++ b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php @@ -13,7 +13,6 @@ use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; use Filament\Actions; -use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; class ListPolicies extends ListRecords @@ -68,10 +67,8 @@ private function makeSyncAction(): Actions\Action ); if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Policy sync already active') - ->body('This operation is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 994a158..29091c9 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -303,20 +303,6 @@ public static function table(Table $table): Table emitQueuedNotification: false, ); - Notification::make() - ->title('Policy version prune queued') - ->body("Queued prune for {$count} policy versions.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->duration(8000) - ->sendToDatabase($initiator); - OperationUxPresenter::queuedToast('policy_version.prune') ->actions([ Actions\Action::make('view_run') @@ -476,20 +462,6 @@ public static function table(Table $table): Table emitQueuedNotification: false, ); - Notification::make() - ->title('Policy version force delete queued') - ->body("Queued force delete for {$count} policy versions.") - ->icon('heroicon-o-arrow-path') - ->iconColor('warning') - ->info() - ->actions([ - Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->duration(8000) - ->sendToDatabase($initiator); - OperationUxPresenter::queuedToast('policy_version.force_delete') ->actions([ Actions\Action::make('view_run') diff --git a/app/Filament/Resources/ProviderConnectionResource.php b/app/Filament/Resources/ProviderConnectionResource.php index cb1fa56..f9eb9b5 100644 --- a/app/Filament/Resources/ProviderConnectionResource.php +++ b/app/Filament/Resources/ProviderConnectionResource.php @@ -18,6 +18,8 @@ use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Providers\ProviderReasonCodes; use App\Support\Rbac\UiEnforcement; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; @@ -586,7 +588,7 @@ public static function table(Table $table): Table ->icon('heroicon-o-check-badge') ->color('success') ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') - ->action(function (ProviderConnection $record, StartVerification $verification): void { + ->action(function (ProviderConnection $record, StartVerification $verification, \Filament\Tables\Contracts\HasTable $livewire): void { $tenant = static::resolveTenantForRecord($record); $user = auth()->user(); @@ -623,10 +625,9 @@ public static function table(Table $table): Table } if ($result->status === 'deduped') { - Notification::make() - ->title('Run already queued') - ->body('A connection check is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->actions([ Actions\Action::make('view_run') ->label('View run') @@ -662,10 +663,9 @@ public static function table(Table $table): Table return; } - Notification::make() - ->title('Connection check queued') - ->body('Health check was queued and will run in the background.') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + OperationUxPresenter::queuedToast((string) $result->run->type) ->actions([ Actions\Action::make('view_run') ->label('View run') @@ -684,7 +684,7 @@ public static function table(Table $table): Table ->icon('heroicon-o-arrow-path') ->color('info') ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void { $tenant = static::resolveTenantForRecord($record); $user = auth()->user(); @@ -725,10 +725,9 @@ public static function table(Table $table): Table } if ($result->status === 'deduped') { - Notification::make() - ->title('Run already queued') - ->body('An inventory sync is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->actions([ Actions\Action::make('view_run') ->label('View run') @@ -758,10 +757,9 @@ public static function table(Table $table): Table return; } - Notification::make() - ->title('Inventory sync queued') - ->body('Inventory sync was queued and will run in the background.') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + OperationUxPresenter::queuedToast((string) $result->run->type) ->actions([ Actions\Action::make('view_run') ->label('View run') @@ -780,7 +778,7 @@ public static function table(Table $table): Table ->icon('heroicon-o-shield-check') ->color('info') ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void { $tenant = static::resolveTenantForRecord($record); $user = auth()->user(); @@ -821,10 +819,9 @@ public static function table(Table $table): Table } if ($result->status === 'deduped') { - Notification::make() - ->title('Run already queued') - ->body('A compliance snapshot is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->actions([ Actions\Action::make('view_run') ->label('View run') @@ -854,10 +851,9 @@ public static function table(Table $table): Table return; } - Notification::make() - ->title('Compliance snapshot queued') - ->body('Compliance snapshot was queued and will run in the background.') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + OperationUxPresenter::queuedToast((string) $result->run->type) ->actions([ Actions\Action::make('view_run') ->label('View run') diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php index 75b17cd..604ef0a 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php @@ -16,6 +16,8 @@ use App\Services\Verification\StartVerification; use App\Support\Auth\Capabilities; use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; use Filament\Actions; use Filament\Actions\Action; @@ -254,10 +256,9 @@ protected function getHeaderActions(): array } if ($result->status === 'deduped') { - Notification::make() - ->title('Run already queued') - ->body('A connection check is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->actions([ Action::make('view_run') ->label('View run') @@ -293,10 +294,9 @@ protected function getHeaderActions(): array return; } - Notification::make() - ->title('Connection check queued') - ->body('Health check was queued and will run in the background.') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::queuedToast((string) $result->run->type) ->actions([ Action::make('view_run') ->label('View run') @@ -493,10 +493,9 @@ protected function getHeaderActions(): array } if ($result->status === 'deduped') { - Notification::make() - ->title('Run already queued') - ->body('An inventory sync is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->actions([ Action::make('view_run') ->label('View run') @@ -526,10 +525,9 @@ protected function getHeaderActions(): array return; } - Notification::make() - ->title('Inventory sync queued') - ->body('Inventory sync was queued and will run in the background.') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::queuedToast((string) $result->run->type) ->actions([ Action::make('view_run') ->label('View run') @@ -606,10 +604,9 @@ protected function getHeaderActions(): array } if ($result->status === 'deduped') { - Notification::make() - ->title('Run already queued') - ->body('A compliance snapshot is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->actions([ Action::make('view_run') ->label('View run') @@ -639,10 +636,9 @@ protected function getHeaderActions(): array return; } - Notification::make() - ->title('Compliance snapshot queued') - ->body('Compliance snapshot was queued and will run in the background.') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::queuedToast((string) $result->run->type) ->actions([ Action::make('view_run') ->label('View run') diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 4aa3b03..8cee1c0 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -1535,11 +1535,23 @@ public static function createRestoreRun(array $data): RestoreRun $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); if ($existing) { - Notification::make() - ->title('Restore already queued') - ->body('Reusing the active restore run.') - ->info() - ->send(); + $existingOpRunId = (int) ($existing->operation_run_id ?? 0); + $existingOpRun = $existingOpRunId > 0 + ? \App\Models\OperationRun::query()->find($existingOpRunId) + : null; + + $toast = OperationUxPresenter::alreadyQueuedToast('restore.execute') + ->body('Reusing the active restore run.'); + + if ($existingOpRun) { + $toast->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($existingOpRun, $tenant)), + ]); + } + + $toast->send(); return $existing; } @@ -1561,11 +1573,23 @@ public static function createRestoreRun(array $data): RestoreRun $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); if ($existing) { - Notification::make() - ->title('Restore already queued') - ->body('Reusing the active restore run.') - ->info() - ->send(); + $existingOpRunId = (int) ($existing->operation_run_id ?? 0); + $existingOpRun = $existingOpRunId > 0 + ? \App\Models\OperationRun::query()->find($existingOpRunId) + : null; + + $toast = OperationUxPresenter::alreadyQueuedToast('restore.execute') + ->body('Reusing the active restore run.'); + + if ($existingOpRun) { + $toast->actions([ + Actions\Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($existingOpRun, $tenant)), + ]); + } + + $toast->send(); return $existing; } @@ -1904,11 +1928,25 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); if ($existing) { - Notification::make() - ->title('Restore already queued') - ->body('Reusing the active restore run.') - ->info() - ->send(); + $existingOpRunId = (int) ($existing->operation_run_id ?? 0); + $existingOpRun = $existingOpRunId > 0 + ? \App\Models\OperationRun::query()->find($existingOpRunId) + : null; + + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + $toast = OperationUxPresenter::alreadyQueuedToast('restore.execute') + ->body('Reusing the active restore run.'); + + if ($existingOpRun) { + $toast->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($existingOpRun, $tenant)), + ]); + } + + $toast->send(); return; } @@ -1930,11 +1968,25 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); if ($existing) { - Notification::make() - ->title('Restore already queued') - ->body('Reusing the active restore run.') - ->info() - ->send(); + $existingOpRunId = (int) ($existing->operation_run_id ?? 0); + $existingOpRun = $existingOpRunId > 0 + ? \App\Models\OperationRun::query()->find($existingOpRunId) + : null; + + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + $toast = OperationUxPresenter::alreadyQueuedToast('restore.execute') + ->body('Reusing the active restore run.'); + + if ($existingOpRun) { + $toast->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($existingOpRun, $tenant)), + ]); + } + + $toast->send(); return; } diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 2353812..2ddd128 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -354,10 +354,8 @@ public static function table(Table $table): Table } if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Policy sync already active') - ->body('This operation is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View Run') @@ -472,6 +470,7 @@ public static function table(Table $table): Table ->action(function ( Tenant $record, StartVerification $verification, + \Filament\Tables\Contracts\HasTable $livewire, ): void { $user = auth()->user(); @@ -496,6 +495,8 @@ public static function table(Table $table): Table $runUrl = OperationRunLinks::tenantlessView($result->run); if ($result->status === 'scope_busy') { + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + Notification::make() ->title('Another operation is already running') ->body('Please wait for the active run to finish.') @@ -511,10 +512,9 @@ public static function table(Table $table): Table } if ($result->status === 'deduped') { - Notification::make() - ->title('Verification already running') - ->body('A verification run is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->actions([ Actions\Action::make('view_run') ->label('View run') @@ -568,9 +568,9 @@ public static function table(Table $table): Table return; } - Notification::make() - ->title('Verification started') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + OperationUxPresenter::queuedToast((string) $result->run->type) ->actions([ Actions\Action::make('view_run') ->label('View run') @@ -753,7 +753,6 @@ public static function table(Table $table): Table ->body('No eligible tenants selected.') ->icon('heroicon-o-information-circle') ->info() - ->sendToDatabase($user) ->send(); return; @@ -1606,10 +1605,7 @@ public static function syncRoleDefinitionsAction(): Actions\Action $runUrl = OperationRunLinks::tenantlessView($opRun); if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Role definitions sync already active') - ->body('This operation is already queued or running.') - ->warning() + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') diff --git a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php index 09157f8..79fd300 100644 --- a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php @@ -17,6 +17,8 @@ use App\Support\Auth\Capabilities; use App\Support\OperationRunLinks; use App\Support\OperationRunType; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; use Filament\Actions; use Filament\Notifications\Notification; @@ -107,6 +109,8 @@ protected function getHeaderActions(): array $runUrl = OperationRunLinks::tenantlessView($result->run); if ($result->status === 'scope_busy') { + OpsUxBrowserEvents::dispatchRunEnqueued($this); + Notification::make() ->title('Another operation is already running') ->body('Please wait for the active run to finish.') @@ -122,10 +126,9 @@ protected function getHeaderActions(): array } if ($result->status === 'deduped') { - Notification::make() - ->title('Verification already running') - ->body('A verification run is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->actions([ Actions\Action::make('view_run') ->label('View run') @@ -179,9 +182,9 @@ protected function getHeaderActions(): array return; } - Notification::make() - ->title('Verification started') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::queuedToast((string) $result->run->type) ->actions([ Actions\Action::make('view_run') ->label('View run') @@ -228,10 +231,9 @@ protected function getHeaderActions(): array $runUrl = OperationRunLinks::tenantlessView($opRun); if ($opRun->wasRecentlyCreated === false) { - Notification::make() - ->title('RBAC health check already running') - ->body('A check is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') @@ -248,9 +250,9 @@ protected function getHeaderActions(): array $opRun, ); - Notification::make() - ->title('RBAC health check started') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::queuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('View run') diff --git a/app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php b/app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php index 33a3ee8..280c23d 100644 --- a/app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php +++ b/app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php @@ -8,9 +8,13 @@ use App\Models\StoredReport; use App\Models\Tenant; use App\Models\User; +use App\Services\OperationRunService; use App\Support\Auth\Capabilities; +use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; +use Filament\Actions\Action; use Filament\Facades\Filament; -use Filament\Notifications\Notification; use Filament\Widgets\Widget; class AdminRolesSummaryWidget extends Widget @@ -54,16 +58,56 @@ public function scanNow(): void abort(403); } - ScanEntraAdminRolesJob::dispatch( - tenantId: (int) $tenant->getKey(), - workspaceId: (int) $tenant->workspace_id, - initiatorUserId: (int) $user->getKey(), + /** @var OperationRunService $operationRuns */ + $operationRuns = app(OperationRunService::class); + + $opRun = $operationRuns->ensureRunWithIdentity( + tenant: $tenant, + type: 'entra.admin_roles.scan', + identityInputs: [ + 'tenant_id' => (int) $tenant->getKey(), + 'trigger' => 'scan', + ], + context: [ + 'workspace_id' => (int) $tenant->workspace_id, + 'initiator_user_id' => (int) $user->getKey(), + ], + initiator: $user, ); - Notification::make() - ->title('Entra admin roles scan queued') + $runUrl = OperationRunLinks::tenantlessView($opRun); + + if ($opRun->wasRecentlyCreated === false) { + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) + ->actions([ + Action::make('view_run') + ->label('View run') + ->url($runUrl), + ]) + ->send(); + + return; + } + + $operationRuns->dispatchOrFail($opRun, function () use ($tenant, $user): void { + ScanEntraAdminRolesJob::dispatch( + tenantId: (int) $tenant->getKey(), + workspaceId: (int) $tenant->workspace_id, + initiatorUserId: (int) $user->getKey(), + ); + }); + + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::queuedToast((string) $opRun->type) ->body('The scan will run in the background. Results appear once complete.') - ->success() + ->actions([ + Action::make('view_run') + ->label('View run') + ->url($runUrl), + ]) ->send(); } diff --git a/app/Filament/Widgets/Tenant/TenantReviewPackCard.php b/app/Filament/Widgets/Tenant/TenantReviewPackCard.php index 4b86707..0a959d2 100644 --- a/app/Filament/Widgets/Tenant/TenantReviewPackCard.php +++ b/app/Filament/Widgets/Tenant/TenantReviewPackCard.php @@ -4,14 +4,19 @@ namespace App\Filament\Widgets\Tenant; +use App\Models\OperationRun; use App\Models\ReviewPack; use App\Models\Tenant; use App\Models\User; use App\Services\ReviewPackService; use App\Support\Auth\Capabilities; +use App\Support\OperationRunLinks; +use App\Support\OperationRunType; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\ReviewPackStatus; +use Filament\Actions\Action; use Filament\Facades\Filament; -use Filament\Notifications\Notification; use Filament\Widgets\Widget; class TenantReviewPackCard extends Widget @@ -58,26 +63,53 @@ public function generatePack(bool $includePii = true, bool $includeOperations = /** @var ReviewPackService $service */ $service = app(ReviewPackService::class); - if ($service->checkActiveRun($tenant)) { - Notification::make() - ->title('Generation already in progress') - ->body('A review pack is currently being generated for this tenant.') - ->warning() + $activeRun = $service->checkActiveRun($tenant) + ? OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', OperationRunType::ReviewPackGenerate->value) + ->active() + ->orderByDesc('id') + ->first() + : null; + + if ($activeRun) { + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::alreadyQueuedToast((string) $activeRun->type) + ->body('A review pack is already queued or running for this tenant.') + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::tenantlessView($activeRun)), + ]) ->send(); return; } - $service->generate($tenant, $user, [ + $reviewPack = $service->generate($tenant, $user, [ 'include_pii' => $includePii, 'include_operations' => $includeOperations, ]); - Notification::make() - ->title('Review pack generation started') - ->body('The pack will be generated in the background. You will be notified when it is ready.') - ->success() - ->send(); + $runUrl = $reviewPack->operationRun + ? OperationRunLinks::tenantlessView($reviewPack->operationRun) + : null; + + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + $toast = OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value) + ->body('The pack will be generated in the background. You will be notified when it is ready.'); + + if ($runUrl !== null) { + $toast->actions([ + Action::make('view_run') + ->label('View run') + ->url($runUrl), + ]); + } + + $toast->send(); } /** diff --git a/app/Filament/Widgets/Tenant/TenantVerificationReport.php b/app/Filament/Widgets/Tenant/TenantVerificationReport.php index c07b176..caf708d 100644 --- a/app/Filament/Widgets/Tenant/TenantVerificationReport.php +++ b/app/Filament/Widgets/Tenant/TenantVerificationReport.php @@ -13,6 +13,8 @@ use App\Support\Auth\UiTooltips; use App\Support\OperationRunLinks; use App\Support\OperationRunStatus; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; use Filament\Actions\Action; use Filament\Facades\Filament; use Filament\Notifications\Notification; @@ -68,6 +70,8 @@ public function startVerification(StartVerification $verification): void $runUrl = OperationRunLinks::tenantlessView($result->run); if ($result->status === 'scope_busy') { + OpsUxBrowserEvents::dispatchRunEnqueued($this); + Notification::make() ->title('Another operation is already running') ->body('Please wait for the active run to finish.') @@ -83,10 +87,9 @@ public function startVerification(StartVerification $verification): void } if ($result->status === 'deduped') { - Notification::make() - ->title('Verification already running') - ->body('A verification run is already queued or running.') - ->warning() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->actions([ Action::make('view_run') ->label('View run') @@ -140,9 +143,9 @@ public function startVerification(StartVerification $verification): void return; } - Notification::make() - ->title('Verification started') - ->success() + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::queuedToast((string) $result->run->type) ->actions([ Action::make('view_run') ->label('View run') diff --git a/app/Jobs/AddPoliciesToBackupSetJob.php b/app/Jobs/AddPoliciesToBackupSetJob.php index d1721eb..4ea1e1f 100644 --- a/app/Jobs/AddPoliciesToBackupSetJob.php +++ b/app/Jobs/AddPoliciesToBackupSetJob.php @@ -13,9 +13,7 @@ use App\Services\Intune\PolicyCaptureOrchestrator; use App\Services\Intune\SnapshotValidator; use App\Services\OperationRunService; -use App\Support\OperationRunLinks; use App\Support\OpsUx\RunFailureSanitizer; -use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Database\Eloquent\Collection as EloquentCollection; @@ -465,48 +463,6 @@ public function handle( failures: $runFailuresForOperationRun, ); - if (! $initiator instanceof User) { - return; - } - - $message = "Added {$succeeded} policies"; - if ($skipped > 0) { - $message .= " ({$skipped} skipped)"; - } - if ($failed > 0) { - $message .= " ({$failed} failed)"; - } - - if ($includeFoundations) { - $message .= ". Foundations: {$foundationMutations} items"; - - if ($foundationFailures > 0) { - $message .= " ({$foundationFailures} failed)"; - } - } - - $message .= '.'; - - $partial = $outcome === 'partially_succeeded' || $foundationFailures > 0; - - $notification = Notification::make() - ->title($partial ? 'Add Policies Completed (partial)' : 'Add Policies Completed') - ->body($message) - ->actions([ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ]); - - if ($partial) { - $notification->warning(); - } else { - $notification->success(); - } - - $notification - ->sendToDatabase($initiator) - ->send(); } catch (Throwable $throwable) { $this->failRun( operationRunService: $operationRunService, @@ -554,31 +510,6 @@ private function failRun( ]], ); - $this->notifyRunFailed($initiator, $tenant, $safeMessage); - } - - private function notifyRunFailed(?User $initiator, ?Tenant $tenant, string $reason): void - { - if (! $initiator instanceof User) { - return; - } - - $notification = Notification::make() - ->title('Add Policies Failed') - ->body($reason); - - if ($tenant instanceof Tenant) { - $notification->actions([ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ]); - } - - $notification - ->danger() - ->sendToDatabase($initiator) - ->send(); } private function mapGraphFailureReasonCode(?int $status): string diff --git a/app/Jobs/ApplyBackupScheduleRetentionJob.php b/app/Jobs/ApplyBackupScheduleRetentionJob.php index 8f0ac8c..39e9bc3 100644 --- a/app/Jobs/ApplyBackupScheduleRetentionJob.php +++ b/app/Jobs/ApplyBackupScheduleRetentionJob.php @@ -6,6 +6,7 @@ use App\Models\BackupSet; use App\Models\OperationRun; use App\Services\Intune\AuditLogger; +use App\Services\OperationRunService; use App\Services\Settings\SettingsResolver; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; @@ -20,7 +21,7 @@ class ApplyBackupScheduleRetentionJob implements ShouldQueue public function __construct(public int $backupScheduleId) {} - public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResolver): void + public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResolver, OperationRunService $operationRunService): void { $schedule = BackupSchedule::query() ->with(['tenant.workspace']) @@ -132,18 +133,18 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol }); } - $operationRun->update([ - 'status' => OperationRunStatus::Completed->value, - 'outcome' => OperationRunOutcome::Succeeded->value, - 'summary_counts' => [ + $operationRunService->updateRun( + $operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + summaryCounts: [ 'total' => (int) $deleteBackupSetIds->count(), 'processed' => (int) $deleteBackupSetIds->count(), 'succeeded' => $deletedCount, 'failed' => max(0, (int) $deleteBackupSetIds->count() - $deletedCount), 'updated' => $deletedCount, ], - 'completed_at' => now(), - ]); + ); $auditLogger->log( tenant: $schedule->tenant, diff --git a/app/Jobs/BulkPolicyExportJob.php b/app/Jobs/BulkPolicyExportJob.php index d04a39a..90a998a 100644 --- a/app/Jobs/BulkPolicyExportJob.php +++ b/app/Jobs/BulkPolicyExportJob.php @@ -10,10 +10,8 @@ use App\Models\Tenant; use App\Models\User; use App\Services\OperationRunService; -use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; -use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -116,21 +114,6 @@ public function handle(OperationRunService $operationRunService): void ); } - if ($user) { - Notification::make() - ->title('Bulk Export Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); - } - return; } @@ -165,21 +148,6 @@ public function handle(OperationRunService $operationRunService): void ); } - if ($user) { - Notification::make() - ->title('Bulk Export Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); - } - return; } @@ -229,21 +197,6 @@ public function handle(OperationRunService $operationRunService): void ); } - if ($user) { - Notification::make() - ->title('Bulk Export Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); - } - return; } } @@ -278,27 +231,6 @@ public function handle(OperationRunService $operationRunService): void ); } - 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() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); - } - } catch (Throwable $e) { if ($this->operationRun) { $operationRunService->updateRun( @@ -311,21 +243,6 @@ public function handle(OperationRunService $operationRunService): void ); } - if (isset($user) && $user instanceof User) { - Notification::make() - ->title('Bulk Export Failed') - ->body($e->getMessage()) - ->icon('heroicon-o-x-circle') - ->danger() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); - } - throw $e; } } diff --git a/app/Jobs/BulkPolicyUnignoreJob.php b/app/Jobs/BulkPolicyUnignoreJob.php index 8fc6b06..1a27d2d 100644 --- a/app/Jobs/BulkPolicyUnignoreJob.php +++ b/app/Jobs/BulkPolicyUnignoreJob.php @@ -8,10 +8,8 @@ use App\Models\Tenant; use App\Models\User; use App\Services\OperationRunService; -use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; -use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -129,32 +127,6 @@ public function handle(OperationRunService $operationRunService): void ); } - if ($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() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); - } } catch (Throwable $e) { if ($this->operationRun) { $operationRunService->updateRun( @@ -167,21 +139,6 @@ public function handle(OperationRunService $operationRunService): void ); } - if (isset($user) && $user instanceof User) { - Notification::make() - ->title('Bulk Restore Failed') - ->body($e->getMessage()) - ->icon('heroicon-o-x-circle') - ->danger() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); - } - throw $e; } } diff --git a/app/Jobs/BulkRestoreRunForceDeleteJob.php b/app/Jobs/BulkRestoreRunForceDeleteJob.php index dd705ee..f2a79c5 100644 --- a/app/Jobs/BulkRestoreRunForceDeleteJob.php +++ b/app/Jobs/BulkRestoreRunForceDeleteJob.php @@ -8,10 +8,8 @@ use App\Models\Tenant; use App\Models\User; use App\Services\OperationRunService; -use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; -use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -104,21 +102,6 @@ public function handle(OperationRunService $operationRunService): void ); } - if ($user) { - Notification::make() - ->title('Bulk Force Delete Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); - } - return; } @@ -158,21 +141,6 @@ public function handle(OperationRunService $operationRunService): void ); } - if ($user) { - Notification::make() - ->title('Bulk Force Delete Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); - } - return; } } @@ -205,39 +173,5 @@ public function handle(OperationRunService $operationRunService): void ); } - $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() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); } } diff --git a/app/Jobs/BulkRestoreRunRestoreJob.php b/app/Jobs/BulkRestoreRunRestoreJob.php index cd91bc7..a07947b 100644 --- a/app/Jobs/BulkRestoreRunRestoreJob.php +++ b/app/Jobs/BulkRestoreRunRestoreJob.php @@ -8,10 +8,8 @@ use App\Models\Tenant; use App\Models\User; use App\Services\OperationRunService; -use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; -use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -103,21 +101,6 @@ public function handle(OperationRunService $operationRunService): void ); } - if ($user) { - Notification::make() - ->title('Bulk Restore Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); - } - return; } @@ -156,21 +139,6 @@ public function handle(OperationRunService $operationRunService): void ); } - if ($user) { - Notification::make() - ->title('Bulk Restore Aborted') - ->body('Circuit breaker triggered: too many failures (>50%).') - ->icon('heroicon-o-exclamation-triangle') - ->danger() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); - } - return; } } @@ -202,39 +170,5 @@ public function handle(OperationRunService $operationRunService): void ); } - $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() - ->actions($this->operationRun ? [ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ] : []) - ->sendToDatabase($user) - ->send(); } } diff --git a/app/Jobs/ExecuteRestoreRunJob.php b/app/Jobs/ExecuteRestoreRunJob.php index d9ec87c..61d3dd7 100644 --- a/app/Jobs/ExecuteRestoreRunJob.php +++ b/app/Jobs/ExecuteRestoreRunJob.php @@ -7,8 +7,6 @@ use App\Listeners\SyncRestoreRunToOperationRun; use App\Models\OperationRun; use App\Models\RestoreRun; -use App\Models\User; -use App\Notifications\RunStatusChangedNotification; use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreService; use App\Support\OpsUx\RunFailureSanitizer; @@ -60,7 +58,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger) return; } - $this->notifyStatus($restoreRun, 'queued'); app(SyncRestoreRunToOperationRun::class)->handle($restoreRun); $tenant = $restoreRun->tenant; @@ -75,8 +72,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger) app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh()); - $this->notifyStatus($restoreRun->refresh(), 'failed'); - if ($tenant) { $auditLogger->log( tenant: $tenant, @@ -134,8 +129,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger) // code performs restore-run updates without firing model events. app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh()); - $this->notifyStatus($restoreRun->refresh(), 'running'); - $auditLogger->log( tenant: $tenant, action: 'restore.started', @@ -163,7 +156,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger) app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh()); - $this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status); } catch (Throwable $throwable) { $restoreRun->refresh(); @@ -179,8 +171,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger) app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh()); - $this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status); - if ($tenant) { $auditLogger->log( tenant: $tenant, @@ -203,45 +193,4 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger) throw $throwable; } } - - private function notifyStatus(RestoreRun $restoreRun, string $status): void - { - $email = $this->actorEmail; - - if (! is_string($email) || $email === '') { - $email = is_string($restoreRun->requested_by) ? $restoreRun->requested_by : null; - } - - if (! is_string($email) || $email === '') { - return; - } - - $user = User::query()->where('email', $email)->first(); - - if (! $user) { - return; - } - - $metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : []; - $counts = []; - - foreach (['total', 'succeeded', 'failed', 'skipped'] as $key) { - if (array_key_exists($key, $metadata) && is_numeric($metadata[$key])) { - $counts[$key] = (int) $metadata[$key]; - } - } - - $payload = [ - 'tenant_id' => (int) $restoreRun->tenant_id, - 'run_type' => 'restore', - 'run_id' => (int) $restoreRun->getKey(), - 'status' => $status, - ]; - - if ($counts !== []) { - $payload['counts'] = $counts; - } - - $user->notify(new RunStatusChangedNotification($payload)); - } } diff --git a/app/Jobs/RemovePoliciesFromBackupSetJob.php b/app/Jobs/RemovePoliciesFromBackupSetJob.php index c9570b5..c5ad526 100644 --- a/app/Jobs/RemovePoliciesFromBackupSetJob.php +++ b/app/Jobs/RemovePoliciesFromBackupSetJob.php @@ -10,9 +10,7 @@ use App\Models\User; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; -use App\Support\OperationRunLinks; use App\Support\OpsUx\RunFailureSanitizer; -use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -180,14 +178,6 @@ public function handle( ); } - $this->notifyCompleted( - initiator: $initiator, - tenant: $tenant instanceof Tenant ? $tenant : null, - removed: $removed, - requested: $requestedCount, - missing: count($missingIds), - outcome: $outcome, - ); } catch (Throwable $throwable) { if ($tenant instanceof Tenant) { $auditLogger->log( @@ -212,100 +202,7 @@ public function handle( $opService->failRun($this->operationRun, $throwable); } - $this->notifyFailed( - initiator: $initiator, - tenant: $tenant instanceof Tenant ? $tenant : null, - reason: RunFailureSanitizer::sanitizeMessage($throwable->getMessage()), - ); - throw $throwable; } } - - private function notifyCompleted( - ?User $initiator, - ?Tenant $tenant, - int $removed, - int $requested, - int $missing, - ?string $outcome, - ): void { - if (! $initiator instanceof User) { - return; - } - - if (! $this->operationRun) { - return; - } - - $message = "Removed {$removed} policies"; - - if ($missing > 0) { - $message .= " ({$missing} missing)"; - } - - if ($requested !== $removed && $missing === 0) { - $skipped = max(0, $requested - $removed); - if ($skipped > 0) { - $message .= " ({$skipped} not removed)"; - } - } - - $message .= '.'; - - $partial = in_array((string) $outcome, ['partially_succeeded'], true) || $missing > 0; - $failed = in_array((string) $outcome, ['failed'], true); - - $notification = Notification::make() - ->title($failed ? 'Removal failed' : ($partial ? 'Removal completed (partial)' : 'Removal completed')) - ->body($message); - - if ($tenant instanceof Tenant) { - $notification->actions([ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ]); - } - - if ($failed) { - $notification->danger(); - } elseif ($partial) { - $notification->warning(); - } else { - $notification->success(); - } - - $notification - ->sendToDatabase($initiator) - ->send(); - } - - private function notifyFailed(?User $initiator, ?Tenant $tenant, string $reason): void - { - if (! $initiator instanceof User) { - return; - } - - if (! $this->operationRun) { - return; - } - - $notification = Notification::make() - ->title('Removal failed') - ->body($reason); - - if ($tenant instanceof Tenant) { - $notification->actions([ - \Filament\Actions\Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ]); - } - - $notification - ->danger() - ->sendToDatabase($initiator) - ->send(); - } } diff --git a/app/Jobs/RunBackupScheduleJob.php b/app/Jobs/RunBackupScheduleJob.php index e04bfc3..55d59ee 100644 --- a/app/Jobs/RunBackupScheduleJob.php +++ b/app/Jobs/RunBackupScheduleJob.php @@ -6,7 +6,6 @@ use App\Models\BackupSchedule; use App\Models\OperationRun; use App\Models\Tenant; -use App\Models\User; use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\BackupScheduling\RunErrorMapper; use App\Services\BackupScheduling\ScheduleTimeService; @@ -14,11 +13,8 @@ use App\Services\Intune\BackupService; use App\Services\Intune\PolicySyncService; use App\Services\OperationRunService; -use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use Carbon\CarbonImmutable; -use Filament\Actions\Action; -use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -162,13 +158,6 @@ public function handle( ], ); - $this->notifyScheduleRunFinished( - tenant: $tenant, - schedule: $schedule, - status: self::STATUS_SKIPPED, - errorMessage: 'Schedule is archived; run will not execute.', - ); - $auditLogger->log( tenant: $tenant, action: 'backup_schedule.run_skipped', @@ -217,13 +206,6 @@ public function handle( ], ); - $this->notifyScheduleRunFinished( - tenant: $tenant, - schedule: $schedule, - status: self::STATUS_SKIPPED, - errorMessage: 'Another run is already in progress for this schedule.', - ); - $auditLogger->log( tenant: $tenant, action: 'backup_schedule.run_skipped', @@ -245,8 +227,6 @@ public function handle( try { $nowUtc = CarbonImmutable::now('UTC'); - $this->notifyScheduleRunStarted(tenant: $tenant, schedule: $schedule); - $auditLogger->log( tenant: $tenant, action: 'backup_schedule.run_started', @@ -291,13 +271,6 @@ public function handle( ], ); - $this->notifyScheduleRunFinished( - tenant: $tenant, - schedule: $schedule, - status: self::STATUS_SKIPPED, - errorMessage: 'All configured policy types are unknown.', - ); - return; } @@ -421,13 +394,6 @@ public function handle( nowUtc: $nowUtc, ); - $this->notifyScheduleRunFinished( - tenant: $tenant, - schedule: $schedule, - status: $status, - errorMessage: $errorMessage, - ); - if (in_array($status, [self::STATUS_SUCCESS, self::STATUS_PARTIAL], true)) { Bus::dispatch(new ApplyBackupScheduleRetentionJob((int) $schedule->getKey())); } @@ -485,13 +451,6 @@ public function handle( ], ); - $this->notifyScheduleRunFinished( - tenant: $tenant, - schedule: $schedule, - status: self::STATUS_FAILED, - errorMessage: (string) $mapped['error_message'], - ); - $auditLogger->log( tenant: $tenant, action: 'backup_schedule.run_failed', @@ -522,80 +481,6 @@ private function resolveBackupScheduleId(): int return is_numeric($contextScheduleId) ? (int) $contextScheduleId : 0; } - private function notifyScheduleRunStarted(Tenant $tenant, BackupSchedule $schedule): void - { - $userId = $this->operationRun?->user_id; - - if (! $userId) { - return; - } - - $user = User::query()->find($userId); - - if (! $user instanceof User) { - return; - } - - Notification::make() - ->title('Backup started') - ->body(sprintf('Schedule "%s" has started.', $schedule->name)) - ->info() - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ]) - ->sendToDatabase($user); - } - - private function notifyScheduleRunFinished( - Tenant $tenant, - BackupSchedule $schedule, - string $status, - ?string $errorMessage, - ): void { - $userId = $this->operationRun?->user_id; - - if (! $userId) { - return; - } - - $user = User::query()->find($userId); - - if (! $user instanceof User) { - return; - } - - $title = match ($status) { - self::STATUS_SUCCESS => 'Backup completed', - self::STATUS_PARTIAL => 'Backup completed (partial)', - self::STATUS_SKIPPED => 'Backup skipped', - default => 'Backup failed', - }; - - $notification = Notification::make() - ->title($title) - ->body(sprintf('Schedule "%s" finished with status: %s.', $schedule->name, $status)); - - if (is_string($errorMessage) && $errorMessage !== '') { - $notification->body($notification->getBody()."\n".$errorMessage); - } - - match ($status) { - self::STATUS_SUCCESS => $notification->success(), - self::STATUS_PARTIAL, self::STATUS_SKIPPED => $notification->warning(), - default => $notification->danger(), - }; - - $notification - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($this->operationRun, $tenant)), - ]) - ->sendToDatabase($user); - } - private function finishSchedule( BackupSchedule $schedule, string $status, diff --git a/app/Livewire/BackupSetPolicyPickerTable.php b/app/Livewire/BackupSetPolicyPickerTable.php index 8ba8d6f..7e8c061 100644 --- a/app/Livewire/BackupSetPolicyPickerTable.php +++ b/app/Livewire/BackupSetPolicyPickerTable.php @@ -320,15 +320,12 @@ public function table(Table $table): Table ); if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Add policies already queued') - ->body('A matching run is already queued or running. Open the run to monitor progress.') + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->actions([ \Filament\Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($opRun, $tenant)), ]) - ->info() ->send(); return; diff --git a/app/Notifications/RunStatusChangedNotification.php b/app/Notifications/RunStatusChangedNotification.php deleted file mode 100644 index 44e93ac..0000000 --- a/app/Notifications/RunStatusChangedNotification.php +++ /dev/null @@ -1,112 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['database']; - } - - /** - * @return array - */ - public function toDatabase(object $notifiable): array - { - $status = (string) ($this->metadata['status'] ?? 'queued'); - $runType = (string) ($this->metadata['run_type'] ?? 'run'); - $tenantId = (int) ($this->metadata['tenant_id'] ?? 0); - $runId = (int) ($this->metadata['run_id'] ?? 0); - - $title = match ($status) { - 'queued' => 'Run queued', - 'running' => 'Run started', - 'completed', 'succeeded' => 'Run completed', - 'partial', 'partially succeeded', 'completed_with_errors' => 'Run completed (partial)', - 'failed' => 'Run failed', - default => 'Run updated', - }; - - $body = sprintf('A %s run changed status to: %s.', str_replace('_', ' ', $runType), $status); - - $color = match ($status) { - 'queued', 'running' => 'gray', - 'completed', 'succeeded' => 'success', - 'partial', 'partially succeeded', 'completed_with_errors' => 'warning', - 'failed' => 'danger', - default => 'gray', - }; - - $actions = []; - - if ($tenantId > 0 && $runId > 0) { - $tenant = Tenant::query()->find($tenantId); - - if ($tenant) { - $url = $runType === 'restore' - ? RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant) - : OperationRunLinks::view($runId, $tenant); - - if (! $url) { - return [ - 'format' => 'filament', - 'title' => $title, - 'body' => $body, - 'color' => $color, - 'duration' => 'persistent', - 'actions' => [], - 'icon' => null, - 'iconColor' => null, - 'status' => null, - 'view' => null, - 'viewData' => [ - 'metadata' => $this->metadata, - ], - ]; - } - - $actions[] = Action::make('view_run') - ->label('View run') - ->url($url) - ->toArray(); - } - } - - return [ - 'format' => 'filament', - 'title' => $title, - 'body' => $body, - 'color' => $color, - 'duration' => 'persistent', - 'actions' => $actions, - 'icon' => null, - 'iconColor' => null, - 'status' => null, - 'view' => null, - 'viewData' => [ - 'metadata' => $this->metadata, - ], - ]; - } -} diff --git a/app/Services/Inventory/InventorySyncService.php b/app/Services/Inventory/InventorySyncService.php index 49c60b6..bb1d03f 100644 --- a/app/Services/Inventory/InventorySyncService.php +++ b/app/Services/Inventory/InventorySyncService.php @@ -8,6 +8,7 @@ use App\Models\Tenant; use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\Graph\GraphResponse; +use App\Services\OperationRunService; use App\Services\Providers\ProviderConnectionResolver; use App\Services\Providers\ProviderGateway; use App\Support\OperationRunOutcome; @@ -30,6 +31,7 @@ public function __construct( private readonly InventoryConcurrencyLimiter $concurrencyLimiter, private readonly ProviderConnectionResolver $providerConnections, private readonly ProviderGateway $providerGateway, + private readonly OperationRunService $operationRuns, ) {} /** @@ -100,9 +102,14 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun ]; $operationRun->update([ - 'status' => OperationRunStatus::Completed->value, - 'outcome' => $operationOutcome, - 'summary_counts' => [ + 'context' => $updatedContext, + ]); + + $this->operationRuns->updateRun( + $operationRun, + status: OperationRunStatus::Completed->value, + outcome: $operationOutcome, + summaryCounts: [ 'total' => count($policyTypes), 'processed' => count($policyTypes), 'succeeded' => $status === 'success' ? count($policyTypes) : max(0, count($policyTypes) - (int) ($result['errors_count'] ?? 0)), @@ -110,10 +117,8 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun 'items' => (int) ($result['items_observed_count'] ?? 0), 'updated' => (int) ($result['items_upserted_count'] ?? 0), ], - 'failure_summary' => $failureSummary, - 'context' => $updatedContext, - 'completed_at' => now(), - ]); + failures: $failureSummary, + ); return $operationRun->refresh(); } diff --git a/app/Services/OperationRunService.php b/app/Services/OperationRunService.php index 6c77483..f3eb209 100644 --- a/app/Services/OperationRunService.php +++ b/app/Services/OperationRunService.php @@ -329,7 +329,7 @@ public function enqueueBulkOperation( callable $dispatcher, ?User $initiator = null, array $extraContext = [], - bool $emitQueuedNotification = true + bool $emitQueuedNotification = false ): OperationRun { $targetScope = BulkRunContext::normalizeTargetScope($targetScope); @@ -543,7 +543,7 @@ public function incrementSummaryCounts(OperationRun $run, array $delta): Operati * If dispatch fails synchronously (misconfiguration, serialization errors, etc.), * the OperationRun is marked terminal failed so we do not leave a misleading queued run behind. */ - public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = true): void + public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = false): void { try { $this->invokeDispatcher($dispatcher, $run); diff --git a/app/Support/OpsUx/OperationUxPresenter.php b/app/Support/OpsUx/OperationUxPresenter.php index 1e2b036..0906ea6 100644 --- a/app/Support/OpsUx/OperationUxPresenter.php +++ b/app/Support/OpsUx/OperationUxPresenter.php @@ -30,6 +30,20 @@ public static function queuedToast(string $operationType): FilamentNotification ->duration(self::QUEUED_TOAST_DURATION_MS); } + /** + * Canonical dedupe feedback when a matching run is already active. + */ + public static function alreadyQueuedToast(string $operationType): FilamentNotification + { + $operationLabel = OperationCatalog::label($operationType); + + return FilamentNotification::make() + ->title("{$operationLabel} already queued") + ->body('A matching run is already queued or running.') + ->info() + ->duration(self::QUEUED_TOAST_DURATION_MS); + } + /** * Terminal DB notification payload. * diff --git a/specs/055-ops-ux-rollout/plan.md b/specs/055-ops-ux-rollout/plan.md index 2a803f2..7531c90 100644 --- a/specs/055-ops-ux-rollout/plan.md +++ b/specs/055-ops-ux-rollout/plan.md @@ -19,7 +19,7 @@ ## Summary ## Technical Context **Language/Version**: PHP 8.4.15 (Laravel 12) -**Primary Dependencies**: Filament v4, Livewire v3 +**Primary Dependencies**: Filament v5, Livewire v4 **Storage**: PostgreSQL (JSONB for `operation_runs.summary_counts`) **Testing**: Pest v4 (Laravel test runner), PHPUnit 12 (via Pest) **Target Platform**: Docker/Sail for local dev, container-based deploy (Dokploy) diff --git a/specs/110-ops-ux-enforcement/plan.md b/specs/110-ops-ux-enforcement/plan.md index e407cbf..7c0dadb 100644 --- a/specs/110-ops-ux-enforcement/plan.md +++ b/specs/110-ops-ux-enforcement/plan.md @@ -17,12 +17,6 @@ ## Summary ## Technical Context - - **Language/Version**: PHP 8.4.x **Primary Dependencies**: Laravel 12, Filament v5, Livewire v4 **Storage**: PostgreSQL (Sail) @@ -37,7 +31,7 @@ ## Technical Context - No queued/running DB notifications anywhere (including `OperationRunQueued`). - Existing RBAC/capability gates for starting operations remain unchanged. -**Scale/Scope**: Repo-wide enforcement via guard tests; remediation limited to enumerated violating flows in the spec. +**Scale/Scope**: Tenant-runtime remediation for enumerated violating flows + repo-wide enforcement via guard tests. ## Constitution Check @@ -69,13 +63,6 @@ ### Documentation (this feature) ``` ### Source Code (repository root) - - ```text app/ ├── Jobs/ @@ -110,8 +97,7 @@ ## Complexity Tracking | Violation | Why Needed | Simpler Alternative Rejected Because | |-----------|------------|-------------------------------------| -| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | -| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | +| None | N/A | N/A | ## Phase 0 — Outline & Research diff --git a/specs/110-ops-ux-enforcement/quickstart.md b/specs/110-ops-ux-enforcement/quickstart.md index 766eb29..bccb50a 100644 --- a/specs/110-ops-ux-enforcement/quickstart.md +++ b/specs/110-ops-ux-enforcement/quickstart.md @@ -3,21 +3,22 @@ # Quickstart: Ops-UX Enforcement & Cleanup ## Prereqs - Sail running: `vendor/bin/sail up -d` +- Sail running: `./vendor/bin/sail up -d` ## Run the focused guard tests Once implemented, run the guard tests only: -- `vendor/bin/sail artisan test --compact --filter=OpsUx` - -If the tests are split by directory as in the spec: - -- `vendor/bin/sail artisan test --compact tests/Feature/OpsUx/Constitution` +- `./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/Constitution` ## Run the focused regression tests -- `vendor/bin/sail artisan test --compact tests/Feature/OpsUx/Regression` +- `./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/Regression` + +## Run the combined Ops-UX pack + +- `./vendor/bin/sail artisan test --compact --group=ops-ux` ## Format touched files -- `vendor/bin/sail bin pint --dirty --format agent` +- `./vendor/bin/sail bin pint --dirty --format agent` diff --git a/specs/110-ops-ux-enforcement/spec.md b/specs/110-ops-ux-enforcement/spec.md index 999d14f..0835978 100644 --- a/specs/110-ops-ux-enforcement/spec.md +++ b/specs/110-ops-ux-enforcement/spec.md @@ -15,7 +15,7 @@ ### Session 2026-02-23 ## Spec Scope Fields *(mandatory)* -- **Scope**: tenant (all operation-run-producing flows within a tenant) +- **Scope**: tenant runtime remediation + repo-wide enforcement guards (CI/static scan) - **Primary Routes**: No new routes. Affected internal flows: Inventory Sync, Backup Schedule Retention, Backup Schedule Run, Bulk Policy Export, Bulk Restore Run Restore, Bulk Restore Run Force Delete, Bulk Policy Unignore, Entra Group manual sync, Add/Remove Policies to Backup Set, Restore Run execution. - **Data Ownership**: `operation_runs` (tenant-scoped), `notifications` (tenant user-scoped). No schema changes required. - **RBAC**: No new RBAC surfaces. Existing capability gates on triggering operations remain unchanged. Notification delivery is limited to the initiator user. System/scheduled runs with no initiator receive no DB notification. @@ -144,7 +144,11 @@ ### Functional Requirements - **FR-010b**: Filament start surfaces that initiate operation-run-producing flows MUST NOT persist queued/running DB notifications (including any `sendToDatabase()` “queued” notifications). Start feedback is toast-only. - **FR-011**: Context-only updates (e.g., updating `context`, `message`, `reason_code` fields without touching `status` or `outcome`) are permitted directly on the model outside `OperationRunService`. - **FR-012**: Three Pest guard tests MUST exist and pass in CI: - - Guard A: Detects direct status/outcome transitions outside `OperationRunService`; reports file + snippet. Implementation MUST scan `app/**/*.php` for `->update(` calls whose update array includes a `status` and/or `outcome` key (multi-line block match allowed), excluding `app/Services/OperationRunService.php`. Context-only updates without `status`/`outcome` MUST NOT fail this guard. + - Guard A: Detects direct status/outcome transitions outside `OperationRunService`; reports file + snippet. Implementation MUST scan `app/**/*.php` for forbidden transition patterns, excluding `app/Services/OperationRunService.php`, including: + - `->update(` calls whose update array includes a `status` and/or `outcome` key (multi-line block match allowed) + - direct assignments to `->status` / `->outcome` + - query/builder/bulk `update([...])` calls that set `status` and/or `outcome` + Context-only updates without `status`/`outcome` MUST NOT fail this guard. - Guard B: Detects DB-notification emissions in operation-flow code; reports file path. Implementation MUST scan `app/**/*.php` and fail when a file contains BOTH (a) an OperationRun signal (`use App\\Models\\OperationRun;` OR `OperationRun` token OR `$this->operationRun` OR `$operationRun`) AND (b) a DB-notification emission (`sendToDatabase(` OR `->notify(`). Allowed exceptions (explicit allowlist): `app/Services/OperationRunService.php`, `app/Notifications/OperationRunCompleted.php`. - Guard C: Detects any reference to `RunStatusChangedNotification` in `app/` or `tests/`. - **FR-012b**: Operation enqueue helpers MUST NOT emit queued DB notifications by default. Any helper param like `emitQueuedNotification` MUST default to `false`, and all current call sites MUST comply. @@ -252,17 +256,19 @@ ## Assumptions 2. The `failed()` job lifecycle callback or try/finally blocks in affected jobs already exist (or will be added) to ensure terminal transitions even on unhandled exceptions — confirmed during implementation per-file. 3. Guard tests are static analysis (filesystem grep-based) Pest tests, not runtime tests. They do not require a running application. 4. The allowlist for Guard B (job DB notifications) is intentionally minimal. Any new entry requires justification in a spec comment. -5. P2 tasks (T110-040/041) are optional and do not gate release of P0/P1 work. +5. P2 tasks (`T026`/`T027`) are optional and do not gate release of P0/P1 work. --- ## Rollout / PR Slicing -| PR | Tasks | Priority | -|----|-------|----------| -| PR-A | T110-001, T110-002 + regression tests (inventory sync, retention) | P0 | -| PR-B | T110-010, T110-011 + restore run tests | P0 | -| PR-C | T110-020, T110-021 + backup schedule tests | P0 | -| PR-D | T110-022–T110-025 + bulk job tests | P1 | -| PR-E | T110-030, T110-031, T110-032 (guards — can land early) | P0 | -| PR-F | T110-040, T110-041 (optional polish) | P2 | +PR slicing is phase/story-based. `tasks.md` remains the source of truth for exact task IDs and sequencing. + +| PR | Scope Slice | Priority | +|----|-------------|----------| +| PR-A | Phase 1–3 (Setup + Foundational + US1 “No Silent Completions”) | P0 | +| PR-B | Phase 4 (US2 “No Notification Spam” cleanup: jobs + start surfaces) | P0/P1 | +| PR-C | Phase 5 (US3 legacy notification removal) | P0 | +| PR-D | Phase 6 (US4 constitution guard tests A/B/C) | P0 | +| PR-E | Phase 7 (US5 canonical “already queued” toast polish) | P2 | +| PR-F | Phase 8 (docs alignment + validation execution) | P1/P2 | diff --git a/specs/110-ops-ux-enforcement/tasks.md b/specs/110-ops-ux-enforcement/tasks.md index 5dcc267..f45630c 100644 --- a/specs/110-ops-ux-enforcement/tasks.md +++ b/specs/110-ops-ux-enforcement/tasks.md @@ -20,7 +20,7 @@ ## Phase 1: Setup (Shared Test Infrastructure) **Purpose**: Create minimal shared helpers for guard tests and keep failure output consistent. -- [ ] T001 [P] Add source scanning helper in tests/Support/OpsUx/SourceFileScanner.php +- [X] T001 [P] Add source scanning helper in tests/Support/OpsUx/SourceFileScanner.php --- @@ -28,8 +28,8 @@ ## Phase 2: Foundational (Blocking Prerequisites) **Purpose**: Cross-cutting invariants that must be true before user story work can be considered “done”. -- [ ] T002 Update queued notification defaults in app/Services/OperationRunService.php (dispatchOrFail + enqueue helpers default emitQueuedNotification=false) -- [ ] T003 Confirm no call sites opt into queued DB notifications in app/Services/OperationRunService.php (remove/forbid emitQueuedNotification:true usage) +- [X] T002 Update queued notification defaults in app/Services/OperationRunService.php (dispatchOrFail + enqueue helpers default emitQueuedNotification=false) +- [X] T003 Confirm repo-wide call sites do not opt into queued DB notifications (remove/forbid `emitQueuedNotification: true` usages in `app/**`) **Checkpoint**: No queued/running DB notifications can be emitted by default. @@ -43,14 +43,14 @@ ## Phase 3: User Story 1 — No Silent Completions (Priority: P1) 🎯 MVP ### Tests (write first) -- [ ] T004 [P] [US1] Add inventory sync terminal notification regression test in tests/Feature/OpsUx/Regression/InventorySyncTerminalNotificationTest.php -- [ ] T005 [P] [US1] Add retention terminal notification regression test in tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php +- [X] T004 [P] [US1] Add inventory sync terminal notification regression test in tests/Feature/OpsUx/Regression/InventorySyncTerminalNotificationTest.php +- [X] T005 [P] [US1] Add retention terminal notification regression test in tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php ### Implementation -- [ ] T006 [US1] Refactor terminal transition in app/Services/Inventory/InventorySyncService.php to use OperationRunService::updateRun() -- [ ] T007 [US1] Refactor terminal transition in app/Jobs/ApplyBackupScheduleRetentionJob.php to use OperationRunService::updateRun() -- [ ] T008 [US1] Refactor OperationRun status/outcome update in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php to use OperationRunService::updateRun() (initiator may be null) +- [X] T006 [US1] Refactor terminal transition in app/Services/Inventory/InventorySyncService.php to use OperationRunService::updateRun() +- [X] T007 [US1] Refactor terminal transition in app/Jobs/ApplyBackupScheduleRetentionJob.php to use OperationRunService::updateRun() +- [X] T008 [US1] Refactor OperationRun status/outcome update in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php to use OperationRunService::updateRun() (initiator may be null) **Checkpoint**: US1 regressions pass, with no silent completions. @@ -64,26 +64,26 @@ ## Phase 4: User Story 2 — No Notification Spam (Priority: P1) ### Tests (write first) -- [ ] T009 [P] [US2] Add backup schedule run notification regression test in tests/Feature/OpsUx/Regression/BackupScheduleRunNotificationTest.php -- [ ] T010 [P] [US2] Add bulk job “abort/circuit-break” regression test in tests/Feature/OpsUx/Regression/BulkJobCircuitBreakerTest.php +- [X] T009 [P] [US2] Add backup schedule run notification regression test in tests/Feature/OpsUx/Regression/BackupScheduleRunNotificationTest.php +- [X] T010 [P] [US2] Add bulk job “abort/circuit-break” regression test in tests/Feature/OpsUx/Regression/BulkJobCircuitBreakerTest.php ### Implementation (Jobs) -- [ ] T011 [P] [US2] Remove queued + custom finished DB notifications in app/Jobs/RunBackupScheduleJob.php -- [ ] T012 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkPolicyExportJob.php -- [ ] T013 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkRestoreRunForceDeleteJob.php -- [ ] T029 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkRestoreRunRestoreJob.php -- [ ] T030 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkPolicyUnignoreJob.php -- [ ] T014 [P] [US2] Remove custom completion/failure DB notifications in app/Jobs/AddPoliciesToBackupSetJob.php -- [ ] T015 [P] [US2] Remove custom completion/failure DB notifications in app/Jobs/RemovePoliciesFromBackupSetJob.php +- [X] T011 [P] [US2] Remove queued + custom finished DB notifications in app/Jobs/RunBackupScheduleJob.php +- [X] T012 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkPolicyExportJob.php +- [X] T013 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkRestoreRunForceDeleteJob.php +- [X] T029 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkRestoreRunRestoreJob.php +- [X] T030 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkPolicyUnignoreJob.php +- [X] T014 [P] [US2] Remove custom completion/failure DB notifications in app/Jobs/AddPoliciesToBackupSetJob.php +- [X] T015 [P] [US2] Remove custom completion/failure DB notifications in app/Jobs/RemovePoliciesFromBackupSetJob.php ### Implementation (Start surfaces / Filament) -- [ ] T016 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/PolicyResource.php (remove sendToDatabase for queued ops) -- [ ] T017 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/BackupScheduleResource.php (remove sendToDatabase for queued ops) -- [ ] T018 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/TenantResource.php (remove sendToDatabase for queued ops) -- [ ] T019 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/PolicyVersionResource.php (remove sendToDatabase for queued ops) -- [ ] T031 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php (remove sendToDatabase for queued ops) +- [X] T016 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/PolicyResource.php (remove sendToDatabase for queued ops) +- [X] T017 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/BackupScheduleResource.php (remove sendToDatabase for queued ops) +- [X] T018 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/TenantResource.php (remove sendToDatabase for queued ops) +- [X] T019 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/PolicyVersionResource.php (remove sendToDatabase for queued ops) +- [X] T031 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php (remove sendToDatabase for queued ops) **Checkpoint**: US2 regressions pass and notifications remain terminal-only. @@ -97,12 +97,12 @@ ## Phase 5: User Story 3 — Legacy Notification Removed (Priority: P1) ### Tests (write first) -- [ ] T020 [P] [US3] Add restore run terminal notification regression test in tests/Feature/OpsUx/Regression/RestoreRunTerminalNotificationTest.php +- [X] T020 [P] [US3] Add restore run terminal notification regression test in tests/Feature/OpsUx/Regression/RestoreRunTerminalNotificationTest.php ### Implementation -- [ ] T021 [US3] Remove legacy notification invocation in app/Jobs/ExecuteRestoreRunJob.php -- [ ] T022 [US3] Delete legacy notification class app/Notifications/RunStatusChangedNotification.php +- [X] T021 [US3] Remove legacy notification invocation in app/Jobs/ExecuteRestoreRunJob.php +- [X] T022 [US3] Delete legacy notification class app/Notifications/RunStatusChangedNotification.php **Checkpoint**: US3 regression passes; no legacy notification remains. @@ -116,9 +116,9 @@ ## Phase 6: User Story 4 — Regression Guards Enforce the Constitution (Priorit ### Guard tests -- [ ] T023 [P] [US4] Implement Guard A in tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php (scan app/** for ->update([...]) containing status/outcome; exclude app/Services/OperationRunService.php; print snippet) -- [ ] T024 [P] [US4] Implement Guard B in tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php (scan app/** for OperationRun signal + DB notify emission; allowlist app/Services/OperationRunService.php and app/Notifications/OperationRunCompleted.php) -- [ ] T025 [P] [US4] Implement Guard C in tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php (scan app/** and tests/** for RunStatusChangedNotification) +- [X] T023 [P] [US4] Implement Guard A in tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php (scan app/** for forbidden status/outcome transitions: ->update([...]) arrays, direct ->status/->outcome assignments, and query/bulk updates; exclude app/Services/OperationRunService.php; print snippet) +- [X] T024 [P] [US4] Implement Guard B in tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php (scan app/** for OperationRun signal + DB notify emission; allowlist app/Services/OperationRunService.php and app/Notifications/OperationRunCompleted.php) +- [X] T025 [P] [US4] Implement Guard C in tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php (scan app/** and tests/** for RunStatusChangedNotification) **Checkpoint**: Guard tests pass green and provide clear failure output. @@ -130,16 +130,34 @@ ## Phase 7: User Story 5 — Canonical "Already Queued" Toast (Priority: P2) **Independent Test**: Trigger dedup path and confirm toast uses `OperationUxPresenter::alreadyQueuedToast(...)`. -- [ ] T026 [P] [US5] Add OperationUxPresenter::alreadyQueuedToast(...) helper in app/Support/OpsUx/OperationUxPresenter.php -- [ ] T027 [US5] Migrate dedup toast to canonical helper in app/Livewire/BackupSetPolicyPickerTable.php +- [X] T026 [P] [US5] Add OperationUxPresenter::alreadyQueuedToast(...) helper in app/Support/OpsUx/OperationUxPresenter.php +- [X] T027 [US5] Migrate dedup toast to canonical helper in app/Livewire/BackupSetPolicyPickerTable.php --- ## Phase 8: Polish & Cross-Cutting Concerns -**Purpose**: Keep docs and developer workflow aligned with final test locations. +**Purpose**: Final documentation alignment plus execution validation (guards, regressions, full suite, formatting). -- [ ] T028 [P] Update quickstart commands/paths if needed in specs/110-ops-ux-enforcement/quickstart.md +- [X] T028 [P] Update quickstart commands/paths if needed in specs/110-ops-ux-enforcement/quickstart.md +- [X] T032 Run focused Ops-UX regression pack (including `tests/Feature/OpsUx/Regression/*`) and confirm green (SC-006 / DoD) +- [X] T033 Run constitution guard tests (`tests/Feature/OpsUx/Constitution/*`) and verify actionable failure output on synthetic violation + green on clean codebase (SC-005) +- [X] T034 Run full test suite (or CI-equivalent command used by this repo) and confirm green (DoD) +- [X] T035 [P] Run Pint for touched files via Sail (`./vendor/bin/sail bin pint --dirty`) and confirm clean (DoD) + +--- + +## Phase 9: Follow-up — Repo-wide Start/Dedup Toast Standardization + +**Purpose**: Ensure all remaining Filament start/dedup surfaces use canonical Ops-UX toasts and trigger immediate progress refresh. + +- [X] T036 Standardize tenant verification start/dedup toasts + progress refresh (Tenant list row, tenant view header, verification widget) +- [X] T037 Standardize review pack generation widget to canonical queued/already queued toasts + view-run action +- [X] T038 Ensure admin roles scan creates/dedupes OperationRun at enqueue time + canonical toasts + progress refresh +- [X] T039 Standardize backup set removal dedupe notifications to canonical already queued toast + progress refresh +- [X] T040 Standardize restore run idempotency “already queued” to canonical already queued toast (with view-run when available) +- [X] T041 Standardize policy bulk delete queued/dedup toast to canonical queued/already queued + progress refresh +- [X] T042 Standardize onboarding wizard verification + bootstrap start/dedup toasts + progress refresh --- diff --git a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php index 7969121..927a327 100644 --- a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php +++ b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php @@ -4,11 +4,8 @@ use App\Jobs\RunBackupScheduleJob; use App\Models\BackupSchedule; use App\Models\OperationRun; -use App\Models\User; -use App\Notifications\OperationRunQueued; use App\Services\Graph\GraphClientInterface; use App\Services\OperationRunService; -use App\Support\OperationRunLinks; use Carbon\CarbonImmutable; use Filament\Facades\Filament; use Illuminate\Support\Facades\Queue; @@ -25,7 +22,7 @@ }); }); -test('operator can run now and it persists a database notification', function () { +test('operator can run now without persisting a database notification', function () { Queue::fake([RunBackupScheduleJob::class]); [$user, $tenant] = createUserWithTenant(role: 'operator'); @@ -67,19 +64,7 @@ && $job->operationRun->is($operationRun); }); - $this->assertDatabaseCount('notifications', 1); - $this->assertDatabaseHas('notifications', [ - 'notifiable_id' => $user->id, - 'notifiable_type' => User::class, - 'type' => OperationRunQueued::class, - 'data->format' => 'filament', - 'data->title' => 'Backup schedule run queued', - ]); - - $notification = $user->notifications()->latest('id')->first(); - expect($notification)->not->toBeNull(); - expect($notification->data['actions'][0]['url'] ?? null) - ->toBe(OperationRunLinks::view($operationRun, $tenant)); + $this->assertDatabaseCount('notifications', 0); }); test('run now is unique per click (no dedupe)', function () { @@ -119,10 +104,10 @@ expect($runs[0])->not->toBe($runs[1]); Queue::assertPushed(RunBackupScheduleJob::class, 2); - $this->assertDatabaseCount('notifications', 2); + $this->assertDatabaseCount('notifications', 0); }); -test('operator can retry and it persists a database notification', function () { +test('operator can retry without persisting a database notification', function () { Queue::fake([RunBackupScheduleJob::class]); [$user, $tenant] = createUserWithTenant(role: 'operator'); @@ -163,19 +148,7 @@ && $job->operationRun instanceof OperationRun && $job->operationRun->is($operationRun); }); - $this->assertDatabaseCount('notifications', 1); - $this->assertDatabaseHas('notifications', [ - 'notifiable_id' => $user->id, - 'notifiable_type' => User::class, - 'type' => OperationRunQueued::class, - 'data->format' => 'filament', - 'data->title' => 'Backup schedule run queued', - ]); - - $notification = $user->notifications()->latest('id')->first(); - expect($notification)->not->toBeNull(); - expect($notification->data['actions'][0]['url'] ?? null) - ->toBe(OperationRunLinks::view($operationRun, $tenant)); + $this->assertDatabaseCount('notifications', 0); }); test('retry is unique per click (no dedupe)', function () { @@ -215,7 +188,7 @@ expect($runs[0])->not->toBe($runs[1]); Queue::assertPushed(RunBackupScheduleJob::class, 2); - $this->assertDatabaseCount('notifications', 2); + $this->assertDatabaseCount('notifications', 0); }); test('readonly cannot dispatch run now or retry', function () { @@ -258,7 +231,7 @@ ->toBe(0); }); -test('operator can bulk run now and it persists a database notification', function () { +test('operator can bulk run now without persisting a database notification', function () { Queue::fake([RunBackupScheduleJob::class]); [$user, $tenant] = createUserWithTenant(role: 'operator'); @@ -311,20 +284,10 @@ ->toBe([$user->id]); Queue::assertPushed(RunBackupScheduleJob::class, 2); - $this->assertDatabaseCount('notifications', 1); - $this->assertDatabaseHas('notifications', [ - 'notifiable_id' => $user->id, - 'data->format' => 'filament', - 'data->title' => 'Runs dispatched', - ]); - - $notification = $user->notifications()->latest('id')->first(); - expect($notification)->not->toBeNull(); - expect($notification->data['actions'][0]['url'] ?? null) - ->toBe(OperationRunLinks::index($tenant)); + $this->assertDatabaseCount('notifications', 0); }); -test('operator can bulk retry and it persists a database notification', function () { +test('operator can bulk retry without persisting a database notification', function () { Queue::fake([RunBackupScheduleJob::class]); [$user, $tenant] = createUserWithTenant(role: 'operator'); @@ -377,17 +340,7 @@ ->toBe([$user->id]); Queue::assertPushed(RunBackupScheduleJob::class, 2); - $this->assertDatabaseCount('notifications', 1); - $this->assertDatabaseHas('notifications', [ - 'notifiable_id' => $user->id, - 'data->format' => 'filament', - 'data->title' => 'Retries dispatched', - ]); - - $notification = $user->notifications()->latest('id')->first(); - expect($notification)->not->toBeNull(); - expect($notification->data['actions'][0]['url'] ?? null) - ->toBe(OperationRunLinks::index($tenant)); + $this->assertDatabaseCount('notifications', 0); }); test('operator can bulk retry even if a previous canonical run exists', function () { diff --git a/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php b/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php index 542ee83..e6321ea 100644 --- a/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php +++ b/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php @@ -4,11 +4,11 @@ use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\OperationRun; +use App\Notifications\OperationRunCompleted; use App\Services\Intune\AuditLogger; use App\Support\OperationRunLinks; -use Filament\Notifications\DatabaseNotification; -it('remove policies job sends completion notification with view link', function () { +it('remove policies job sends canonical terminal notification with view link', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); $backupSet = BackupSet::factory()->create([ @@ -54,7 +54,7 @@ $this->assertDatabaseHas('notifications', [ 'notifiable_id' => $user->getKey(), 'notifiable_type' => $user->getMorphClass(), - 'type' => DatabaseNotification::class, + 'type' => OperationRunCompleted::class, ]); $notification = $user->notifications()->latest('id')->first(); diff --git a/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php b/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php index 3e4554b..fb663aa 100644 --- a/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php +++ b/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php @@ -9,6 +9,7 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Intune\BackupService; +use App\Support\OpsUx\OperationUxPresenter; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; @@ -125,6 +126,13 @@ ->count())->toBe(1); Queue::assertPushed(AddPoliciesToBackupSetJob::class, 1); + + $notifications = session('filament.notifications', []); + $expectedToast = OperationUxPresenter::alreadyQueuedToast('backup_set.add_policies'); + + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['title'] ?? null)->toBe($expectedToast->getTitle()); + expect(collect($notifications)->last()['body'] ?? null)->toBe($expectedToast->getBody()); }); test('policy picker table forbids readonly users from starting add policies (403)', function () { diff --git a/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php b/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php new file mode 100644 index 0000000..6777cde --- /dev/null +++ b/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php @@ -0,0 +1,55 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]); + + BaselineTenantAssignment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + Livewire::test(BaselineCompareLanding::class) + ->callAction('compareNow') + ->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey()); + + Queue::assertPushed(CompareBaselineToTenantJob::class); + + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'baseline_compare') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('queued'); +}); diff --git a/tests/Feature/Filament/CreateCtaPlacementTest.php b/tests/Feature/Filament/CreateCtaPlacementTest.php index dfff483..128127f 100644 --- a/tests/Feature/Filament/CreateCtaPlacementTest.php +++ b/tests/Feature/Filament/CreateCtaPlacementTest.php @@ -222,9 +222,9 @@ function getHeaderAction(Testable $component, string $name): ?Action ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]); $component = Livewire::test(ListTenants::class) - ->assertTableEmptyStateActionsExistInOrder(['create']); + ->assertTableEmptyStateActionsExistInOrder(['add_tenant']); - $headerCreate = getHeaderAction($component, 'create'); + $headerCreate = getHeaderAction($component, 'add_tenant'); expect($headerCreate)->not->toBeNull(); expect($headerCreate?->isVisible())->toBeFalse(); }); @@ -237,7 +237,7 @@ function getHeaderAction(Testable $component, string $name): ?Action $component = Livewire::test(ListTenants::class) ->assertCountTableRecords(1); - $headerCreate = getHeaderAction($component, 'create'); + $headerCreate = getHeaderAction($component, 'add_tenant'); expect($headerCreate)->not->toBeNull(); expect($headerCreate?->isVisible())->toBeTrue(); }); diff --git a/tests/Feature/Notifications/OperationRunNotificationTest.php b/tests/Feature/Notifications/OperationRunNotificationTest.php index 07f05ad..5a74120 100644 --- a/tests/Feature/Notifications/OperationRunNotificationTest.php +++ b/tests/Feature/Notifications/OperationRunNotificationTest.php @@ -24,7 +24,7 @@ $service->dispatchOrFail($run, function (): void { // no-op (dispatch succeeded) - }); + }, emitQueuedNotification: true); $this->assertDatabaseHas('notifications', [ 'notifiable_id' => $user->getKey(), @@ -58,7 +58,7 @@ $service->dispatchOrFail($run, function (): void { // no-op - }); + }, emitQueuedNotification: true); expect($user->notifications()->count())->toBe(0); }); diff --git a/tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php b/tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php new file mode 100644 index 0000000..e1625a8 --- /dev/null +++ b/tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php @@ -0,0 +1,66 @@ + '/(?:\$this->operationRun|\$operationRun|\$opRun)\s*->\s*update\s*\(\s*\[(?:(?!\)\s*;).)*?(?:[\'"]status[\'"]|[\'"]outcome[\'"])\s*=>/s', + 'direct fill()/forceFill() with status/outcome' => '/(?:\$this->operationRun|\$operationRun|\$opRun)\s*->\s*(?:fill|forceFill)\s*\(\s*\[(?:(?!\)\s*;).)*?(?:[\'"]status[\'"]|[\'"]outcome[\'"])\s*=>/s', + 'direct property assignment' => '/(?:\$this->operationRun|\$operationRun|\$opRun)\s*->\s*(?:status|outcome)\s*=(?!=)/', + 'OperationRun query/bulk update with status/outcome' => '/OperationRun::(?:(?!;).){0,800}?->\s*update\s*\(\s*\[(?:(?!\)\s*;).)*?(?:[\'"]status[\'"]|[\'"]outcome[\'"])\s*=>/s', + ]; + + $violations = []; + + foreach ($files as $file) { + $source = SourceFileScanner::read($file); + + foreach ($patterns as $label => $pattern) { + if (! preg_match_all($pattern, $source, $matches, PREG_OFFSET_CAPTURE)) { + continue; + } + + foreach ($matches[0] as [$snippetMatch, $offset]) { + if (! is_int($offset)) { + continue; + } + + $line = substr_count(substr($source, 0, $offset), "\n") + 1; + + $violations[] = [ + 'file' => SourceFileScanner::relativePath($file), + 'line' => $line, + 'label' => $label, + 'snippet' => SourceFileScanner::snippet($source, $line), + 'match' => trim((string) $snippetMatch), + ]; + } + } + } + + if ($violations !== []) { + $messages = array_map(static function (array $violation): string { + return sprintf( + "%s:%d [%s]\n%s", + $violation['file'], + $violation['line'], + $violation['label'], + $violation['snippet'], + ); + }, $violations); + + $this->fail( + "Forbidden direct OperationRun status/outcome transition(s) found outside OperationRunService:\n\n" + .implode("\n\n", $messages) + ); + } + + expect($violations)->toBe([]); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php b/tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php new file mode 100644 index 0000000..463b552 --- /dev/null +++ b/tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php @@ -0,0 +1,71 @@ + SourceFileScanner::relativePath($file), + 'line' => $line, + 'snippet' => SourceFileScanner::snippet($source, $line), + ]; + } + } + + if ($violations !== []) { + $messages = array_map(static function (array $violation): string { + return sprintf( + "%s:%d\n%s", + $violation['file'], + $violation['line'], + $violation['snippet'], + ); + }, $violations); + + $this->fail( + "Forbidden OperationRun-related database notification emission found (use canonical OperationRunService terminal notification / toast-only start feedback):\n\n" + .implode("\n\n", $messages) + ); + } + + expect($violations)->toBe([]); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php b/tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php new file mode 100644 index 0000000..0ce378f --- /dev/null +++ b/tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php @@ -0,0 +1,54 @@ + SourceFileScanner::relativePath($file), + 'line' => $line, + 'snippet' => SourceFileScanner::snippet($source, $line), + ]; + + $offset = $position + strlen($needle); + } + } + + if ($violations !== []) { + $messages = array_map(static function (array $violation): string { + return sprintf( + "%s:%d\n%s", + $violation['file'], + $violation['line'], + $violation['snippet'], + ); + }, $violations); + + $this->fail( + "Legacy notification reference(s) found:\n\n".implode("\n\n", $messages) + ); + } + + expect($violations)->toBe([]); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php b/tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php index d387753..734a5cb 100644 --- a/tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php +++ b/tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php @@ -6,7 +6,7 @@ use App\Services\OperationRunService; use Filament\Facades\Filament; -it('emits at most one queued database notification per newly created run', function (): void { +it('emits at most one queued database notification per newly created run when explicitly enabled', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->actingAs($user); @@ -25,7 +25,7 @@ $service->dispatchOrFail($run, function (): void { // no-op (dispatch succeeded) - }); + }, emitQueuedNotification: true); expect($user->notifications()->count())->toBe(1); $this->assertDatabaseHas('notifications', [ diff --git a/tests/Feature/OpsUx/NotificationViewRunLinkTest.php b/tests/Feature/OpsUx/NotificationViewRunLinkTest.php index f520178..7553c4d 100644 --- a/tests/Feature/OpsUx/NotificationViewRunLinkTest.php +++ b/tests/Feature/OpsUx/NotificationViewRunLinkTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use App\Models\OperationRun; -use App\Notifications\RunStatusChangedNotification; use App\Services\OperationRunService; use App\Support\OperationRunLinks; use Filament\Facades\Filament; @@ -42,8 +41,11 @@ ->toBe(OperationRunLinks::view($run, $tenant)); })->group('ops-ux'); -it('does not link to legacy bulk run resources in status-change notifications', function (): void { +it('does not link to legacy bulk run resources in canonical terminal notifications', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + Filament::setTenant($tenant, true); $run = OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), @@ -55,12 +57,16 @@ 'context' => ['operation' => ['type' => 'policy.delete']], ]); - $user->notify(new RunStatusChangedNotification([ - 'tenant_id' => (int) $tenant->getKey(), - 'run_type' => 'bulk_operation', - 'run_id' => (int) $run->getKey(), - 'status' => 'completed', - ])); + /** @var OperationRunService $service */ + $service = app(OperationRunService::class); + + $service->updateRun( + $run, + status: 'completed', + outcome: 'succeeded', + summaryCounts: ['total' => 1], + failures: [], + ); $notification = $user->notifications()->latest('id')->first(); expect($notification)->not->toBeNull(); diff --git a/tests/Feature/OpsUx/QueuedToastCopyTest.php b/tests/Feature/OpsUx/QueuedToastCopyTest.php index 2247e6c..53d2aea 100644 --- a/tests/Feature/OpsUx/QueuedToastCopyTest.php +++ b/tests/Feature/OpsUx/QueuedToastCopyTest.php @@ -20,3 +20,10 @@ expect($duration)->toBeGreaterThanOrEqual(3000); expect($duration)->toBeLessThanOrEqual(5000); })->group('ops-ux'); + +it('builds canonical already-queued toast copy', function (): void { + $toast = OperationUxPresenter::alreadyQueuedToast('backup_set.add_policies'); + + expect($toast->getTitle())->toBe('Backup set update already queued'); + expect($toast->getBody())->toBe('A matching run is already queued or running.'); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php b/tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php new file mode 100644 index 0000000..4053158 --- /dev/null +++ b/tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php @@ -0,0 +1,75 @@ +create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Retention Regression', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 2, + ]); + + $sets = collect(range(1, 4))->map(function (int $index) use ($tenant): BackupSet { + return BackupSet::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Retention Set '.$index, + 'status' => 'completed', + 'item_count' => 0, + 'completed_at' => now()->subMinutes(10 - $index), + ]); + }); + + $completedAt = now('UTC')->startOfMinute()->subMinutes(8); + + foreach ($sets as $set) { + OperationRun::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => null, + 'initiator_name' => 'System', + 'type' => 'backup_schedule_run', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'run_identity_hash' => hash('sha256', 'ops-ux-retention-regression:'.$schedule->id.':'.$set->id), + 'summary_counts' => [], + 'failure_summary' => [], + 'context' => [ + 'backup_schedule_id' => (int) $schedule->id, + 'backup_set_id' => (int) $set->id, + ], + 'started_at' => $completedAt, + 'completed_at' => $completedAt, + ]); + + $completedAt = $completedAt->addMinute(); + } + + ApplyBackupScheduleRetentionJob::dispatchSync((int) $schedule->getKey()); + + $retentionRun = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'backup_schedule_retention') + ->latest('id') + ->first(); + + expect($retentionRun)->not->toBeNull(); + expect($retentionRun?->status)->toBe('completed'); + expect($retentionRun?->outcome)->toBe('succeeded'); + expect((int) ($retentionRun?->summary_counts['processed'] ?? 0))->toBe(2); + expect($user->notifications()->count())->toBe(0); + $this->assertDatabaseCount('notifications', 0); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/Regression/BackupScheduleRunNotificationTest.php b/tests/Feature/OpsUx/Regression/BackupScheduleRunNotificationTest.php new file mode 100644 index 0000000..a917900 --- /dev/null +++ b/tests/Feature/OpsUx/Regression/BackupScheduleRunNotificationTest.php @@ -0,0 +1,113 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'OpsUx Regression Schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => null, + ]); + + /** @var OperationRunService $operationRuns */ + $operationRuns = app(OperationRunService::class); + $run = $operationRuns->ensureRun( + tenant: $tenant, + type: 'backup_schedule_run', + inputs: ['backup_schedule_id' => (int) $schedule->getKey()], + initiator: $user, + ); + + app()->bind(PolicySyncService::class, fn (): PolicySyncService => new class extends PolicySyncService + { + public function __construct() {} + + public function syncPoliciesWithReport($tenant, ?array $supportedTypes = null): array + { + return ['synced' => [], 'failures' => []]; + } + }); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'status' => 'completed', + 'item_count' => 0, + ]); + + app()->bind(BackupService::class, fn (): BackupService => new class($backupSet) extends BackupService + { + public function __construct(private readonly BackupSet $backupSet) {} + + public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, bool $includeAssignments = false, bool $includeScopeTags = false, bool $includeFoundations = false): BackupSet + { + return $this->backupSet; + } + }); + + Cache::flush(); + + (new RunBackupScheduleJob(operationRun: $run, backupScheduleId: (int) $schedule->getKey()))->handle( + app(PolicySyncService::class), + app(BackupService::class), + app(PolicyTypeResolver::class), + app(ScheduleTimeService::class), + app(AuditLogger::class), + app(RunErrorMapper::class), + ); + + $run->refresh(); + + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + expect($user->notifications()->count())->toBe(1); + + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => OperationRunCompleted::class, + ]); + + $this->assertDatabaseMissing('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => DatabaseNotification::class, + ]); + + Bus::assertDispatched(ApplyBackupScheduleRetentionJob::class); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/Regression/BulkJobCircuitBreakerTest.php b/tests/Feature/OpsUx/Regression/BulkJobCircuitBreakerTest.php new file mode 100644 index 0000000..b9f9073 --- /dev/null +++ b/tests/Feature/OpsUx/Regression/BulkJobCircuitBreakerTest.php @@ -0,0 +1,55 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + /** @var OperationRunService $operationRuns */ + $operationRuns = app(OperationRunService::class); + $run = $operationRuns->ensureRun( + tenant: $tenant, + type: 'policy.export', + inputs: ['scope' => 'subset', 'policy_ids' => [999_999_991]], + initiator: $user, + ); + + $job = new BulkPolicyExportJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + policyIds: [999_999_991], + backupName: 'OpsUx Circuit Breaker Regression', + operationRun: $run, + ); + + $job->handle($operationRuns); + + $run->refresh(); + + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('failed'); + expect((int) ($run->summary_counts['failed'] ?? 0))->toBeGreaterThan(0); + expect($user->notifications()->count())->toBe(1); + + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => OperationRunCompleted::class, + ]); + + $this->assertDatabaseMissing('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => DatabaseNotification::class, + ]); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/Regression/InventorySyncTerminalNotificationTest.php b/tests/Feature/OpsUx/Regression/InventorySyncTerminalNotificationTest.php new file mode 100644 index 0000000..242fbfa --- /dev/null +++ b/tests/Feature/OpsUx/Regression/InventorySyncTerminalNotificationTest.php @@ -0,0 +1,77 @@ +defaultSelectionPayload(); + $computed = $sync->normalizeAndHashSelection($selectionPayload); + + $mockSync = \Mockery::mock(InventorySyncService::class); + $mockSync + ->shouldReceive('executeSelection') + ->once() + ->andReturn([ + 'status' => 'success', + 'had_errors' => false, + 'error_codes' => [], + 'error_context' => [], + 'errors_count' => 0, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'skipped_policy_types' => [], + 'processed_policy_types' => $computed['selection']['policy_types'], + 'failed_policy_types' => [], + 'selection_hash' => $computed['selection_hash'], + ]); + + /** @var OperationRunService $operationRuns */ + $operationRuns = app(OperationRunService::class); + $run = $operationRuns->ensureRun( + tenant: $tenant, + type: 'inventory_sync', + inputs: $computed['selection'], + initiator: $user, + ); + + $job = new RunInventorySyncJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + operationRun: $run, + ); + + expect($user->notifications()->count())->toBe(0); + + $job->handle($mockSync, app(AuditLogger::class), $operationRuns); + + $run->refresh(); + + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + expect($user->notifications()->count())->toBe(1); + + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => OperationRunCompleted::class, + ]); +})->group('ops-ux'); + +it('does not persist terminal notifications for system-run inventory syncs without initiator', function (): void { + [, $tenant] = createUserWithTenant(role: 'owner'); + + $sync = app(InventorySyncService::class); + $run = $sync->syncNow($tenant, $sync->defaultSelectionPayload()); + + expect($run->status)->toBe('completed'); + expect($tenant->users()->firstOrFail()->notifications()->count())->toBe(0); + $this->assertDatabaseCount('notifications', 0); +})->group('ops-ux'); diff --git a/tests/Feature/OpsUx/Regression/RestoreRunTerminalNotificationTest.php b/tests/Feature/OpsUx/Regression/RestoreRunTerminalNotificationTest.php new file mode 100644 index 0000000..1efd1bb --- /dev/null +++ b/tests/Feature/OpsUx/Regression/RestoreRunTerminalNotificationTest.php @@ -0,0 +1,87 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $restoreRun = RestoreRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'requested_by' => $user->email, + 'status' => 'queued', + 'started_at' => null, + 'completed_at' => null, + ]); + + /** @var OperationRunService $operationRuns */ + $operationRuns = app(OperationRunService::class); + $operationRun = $operationRuns->ensureRun( + tenant: $tenant, + type: 'restore.execute', + inputs: [ + 'restore_run_id' => (int) $restoreRun->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false), + ], + initiator: $user, + ); + + $operationRun->forceFill([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + ])->save(); + + $this->mock(RestoreService::class, function ($mock) use ($restoreRun): void { + $mock->shouldReceive('executeForRun') + ->once() + ->andReturnUsing(function () use ($restoreRun): RestoreRun { + RestoreRun::query()->whereKey($restoreRun->getKey())->update([ + 'status' => 'completed', + 'completed_at' => now(), + ]); + + return RestoreRun::query()->findOrFail($restoreRun->getKey()); + }); + }); + + $job = new ExecuteRestoreRunJob( + restoreRunId: (int) $restoreRun->getKey(), + actorEmail: $user->email, + actorName: $user->name, + operationRun: $operationRun, + ); + + expect($user->notifications()->count())->toBe(0); + + $job->handle(app(RestoreService::class), app(AuditLogger::class)); + + $operationRun->refresh(); + + expect($operationRun->status)->toBe('completed'); + expect($operationRun->outcome)->toBe('succeeded'); + expect($user->notifications()->count())->toBe(1); + + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => OperationRunCompleted::class, + ]); +})->group('ops-ux'); diff --git a/tests/Feature/ReviewPack/ReviewPackGenerationTest.php b/tests/Feature/ReviewPack/ReviewPackGenerationTest.php index e6f829c..59ec017 100644 --- a/tests/Feature/ReviewPack/ReviewPackGenerationTest.php +++ b/tests/Feature/ReviewPack/ReviewPackGenerationTest.php @@ -303,7 +303,7 @@ function seedTenantWithData(Tenant $tenant): void }); }); -it('sends queued database notification when review pack generation is requested', function (): void { +it('does not send queued database notification when review pack generation is requested', function (): void { Queue::fake(); Notification::fake(); @@ -313,7 +313,7 @@ function seedTenantWithData(Tenant $tenant): void $service = app(ReviewPackService::class); $service->generate($tenant, $user); - Notification::assertSentTo($user, OperationRunQueued::class); + Notification::assertNotSentTo($user, OperationRunQueued::class); }); // ─── OperationRun Type ────────────────────────────────────────── diff --git a/tests/Support/OpsUx/SourceFileScanner.php b/tests/Support/OpsUx/SourceFileScanner.php new file mode 100644 index 0000000..ffa076f --- /dev/null +++ b/tests/Support/OpsUx/SourceFileScanner.php @@ -0,0 +1,103 @@ + $roots + * @param list $excludedAbsolutePaths + * @return list + */ + public static function phpFiles(array $roots, array $excludedAbsolutePaths = []): array + { + $files = []; + $excluded = array_fill_keys(array_map(self::normalizePath(...), $excludedAbsolutePaths), true); + + foreach ($roots as $root) { + $root = self::normalizePath($root); + + if (! is_dir($root)) { + continue; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if (! $file->isFile()) { + continue; + } + + $path = self::normalizePath($file->getPathname()); + + if (pathinfo($path, PATHINFO_EXTENSION) !== 'php') { + continue; + } + + if (isset($excluded[$path])) { + continue; + } + + $files[] = $path; + } + } + + sort($files); + + return array_values(array_unique($files)); + } + + public static function projectRoot(): string + { + return self::normalizePath(dirname(__DIR__, 3)); + } + + public static function relativePath(string $absolutePath): string + { + $absolutePath = self::normalizePath($absolutePath); + $root = self::projectRoot(); + + if (str_starts_with($absolutePath, $root.'/')) { + return substr($absolutePath, strlen($root) + 1); + } + + return $absolutePath; + } + + public static function read(string $path): string + { + return (string) file_get_contents($path); + } + + public static function snippet(string $source, int $line, int $contextLines = 2): string + { + $allLines = preg_split('/\R/', $source) ?: []; + $line = max(1, $line); + + $start = max(1, $line - $contextLines); + $end = min(count($allLines), $line + $contextLines); + + $snippet = []; + + for ($index = $start; $index <= $end; $index++) { + $prefix = $index === $line ? '>' : ' '; + $snippet[] = sprintf('%s%4d | %s', $prefix, $index, $allLines[$index - 1] ?? ''); + } + + return implode("\n", $snippet); + } + + private static function normalizePath(string $path): string + { + return str_replace('\\', '/', $path); + } +}