feat(ops-ux): enterprise start/dedup standard

This commit is contained in:
Ahmed Darrazi 2026-02-24 10:15:34 +01:00
parent 29112225b6
commit 0de0494426
57 changed files with 1305 additions and 1130 deletions

View File

@ -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();
});
}

View File

@ -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')

View File

@ -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<string, int>}|array{status: 'scope_busy', run: OperationRun} $result */
/** @var array{status: 'started', runs: array<string, int>, created: array<string, bool>}|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(

View File

@ -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();

View File

@ -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')

View File

@ -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();
});
}

View File

@ -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();
})
)

View File

@ -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')

View File

@ -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')

View File

@ -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')

View File

@ -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')

View File

@ -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')

View File

@ -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')

View File

@ -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;
}

View File

@ -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')

View File

@ -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')

View File

@ -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();
}

View File

@ -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();
}
/**

View File

@ -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')

View File

@ -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

View File

@ -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,

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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));
}
}

View File

@ -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();
}
}

View File

@ -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,

View File

@ -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;

View File

@ -1,112 +0,0 @@
<?php
namespace App\Notifications;
use App\Filament\Resources\RestoreRunResource;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use Filament\Actions\Action;
use Illuminate\Notifications\Notification;
class RunStatusChangedNotification extends Notification
{
/**
* @param array{
* tenant_id:int,
* run_type:string,
* run_id:int,
* status:string,
* counts?:array{total?:int, processed?:int, succeeded?:int, failed?:int, skipped?:int}
* } $metadata
*/
public function __construct(public array $metadata) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* @return array<string, mixed>
*/
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,
],
];
}
}

View File

@ -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();
}

View File

@ -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);

View File

@ -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.
*

View File

@ -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)

View File

@ -17,12 +17,6 @@ ## Summary
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**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)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```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

View File

@ -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`

View File

@ -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-022T110-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 13 (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 |

View File

@ -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
---

View File

@ -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 () {

View File

@ -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();

View File

@ -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 () {

View File

@ -0,0 +1,55 @@
<?php
use App\Filament\Pages\BaselineCompareLanding;
use App\Jobs\CompareBaselineToTenantJob;
use App\Livewire\BulkOperationProgress;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('dispatches ops-ux run-enqueued after starting baseline compare', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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');
});

View File

@ -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();
});

View File

@ -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);
});

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use Tests\Support\OpsUx\SourceFileScanner;
it('does not allow direct OperationRun status or outcome transitions outside OperationRunService', function (): void {
$root = SourceFileScanner::projectRoot();
$excluded = [$root.'/app/Services/OperationRunService.php'];
$files = SourceFileScanner::phpFiles([$root.'/app'], $excluded);
$patterns = [
'direct update() with status/outcome' => '/(?:\$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');

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
use Tests\Support\OpsUx\SourceFileScanner;
it('does not emit database notifications from OperationRun-producing jobs or start surfaces', function (): void {
$root = SourceFileScanner::projectRoot();
$allowlist = [
$root.'/app/Services/OperationRunService.php',
$root.'/app/Notifications/OperationRunCompleted.php',
];
$files = SourceFileScanner::phpFiles([$root.'/app'], $allowlist);
$violations = [];
foreach ($files as $file) {
$source = SourceFileScanner::read($file);
if (! str_contains($source, 'sendToDatabase(')) {
continue;
}
$hasOperationRunSignal = str_contains($source, 'OperationRun')
|| str_contains($source, 'operationRun')
|| str_contains($source, 'OperationRunLinks')
|| str_contains($source, 'OperationUxPresenter');
if (! $hasOperationRunSignal) {
continue;
}
if (! preg_match_all('/sendToDatabase\s*\(/', $source, $matches, PREG_OFFSET_CAPTURE)) {
continue;
}
foreach ($matches[0] as [, $offset]) {
if (! is_int($offset)) {
continue;
}
$line = substr_count(substr($source, 0, $offset), "\n") + 1;
$violations[] = [
'file' => 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');

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use Tests\Support\OpsUx\SourceFileScanner;
it('does not reference the removed legacy run status notification', function (): void {
$root = SourceFileScanner::projectRoot();
$files = SourceFileScanner::phpFiles([$root.'/app', $root.'/tests'], [
__FILE__,
]);
$needle = 'RunStatus'.'ChangedNotification';
$violations = [];
foreach ($files as $file) {
$source = SourceFileScanner::read($file);
if (! str_contains($source, $needle)) {
continue;
}
$offset = 0;
while (($position = strpos($source, $needle, $offset)) !== false) {
$line = substr_count(substr($source, 0, $position), "\n") + 1;
$violations[] = [
'file' => 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');

View File

@ -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', [

View File

@ -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();

View File

@ -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');

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use App\Jobs\ApplyBackupScheduleRetentionJob;
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\OperationRun;
it('completes backup retention runs without persisting terminal notifications for system runs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$schedule = BackupSchedule::query()->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');

View File

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
use App\Jobs\ApplyBackupScheduleRetentionJob;
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Notifications\OperationRunCompleted;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\RunErrorMapper;
use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\BackupService;
use App\Services\Intune\PolicySyncService;
use App\Services\OperationRunService;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Filament\Notifications\DatabaseNotification;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
it('persists only the canonical terminal notification for initiated backup schedule runs', function (): void {
Bus::fake();
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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');

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use App\Jobs\BulkPolicyExportJob;
use App\Notifications\OperationRunCompleted;
use App\Services\OperationRunService;
use Filament\Facades\Filament;
use Filament\Notifications\DatabaseNotification;
it('persists only the canonical terminal notification when bulk export aborts via circuit breaker', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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');

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
use App\Jobs\RunInventorySyncJob;
use App\Notifications\OperationRunCompleted;
use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService;
it('persists exactly one terminal notification for initiated inventory sync runs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$sync = app(InventorySyncService::class);
$selectionPayload = $sync->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');

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Notifications\OperationRunCompleted;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService;
use App\Services\OperationRunService;
use Filament\Facades\Filament;
it('persists exactly one canonical terminal notification for initiated restore execution runs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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');

View File

@ -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 ──────────────────────────────────────────

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Tests\Support\OpsUx;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
final class SourceFileScanner
{
/**
* @param list<string> $roots
* @param list<string> $excludedAbsolutePaths
* @return list<string>
*/
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);
}
}