feat(110): Ops-UX enterprise start/dedup standard (repo-wide) (#134)

Implements Spec 110 Ops‑UX Enforcement and applies the repo‑wide “enterprise” standard for operation start + dedup surfaces.

Key points
- Start surfaces: only ephemeral queued toast (no DB notifications for started/queued/running).
- Dedup paths: canonical “already queued” toast.
- Progress refresh: dispatch run-enqueued browser event so the global widget updates immediately.
- Completion: exactly-once terminal DB notification on completion (per Ops‑UX contract).

Tests & formatting
- Full suite: 1738 passed, 8 skipped (8477 assertions).
- Pint: `vendor/bin/sail bin pint --dirty --format agent` (pass).

Notable change
- Removed legacy `RunStatusChangedNotification` (replaced by the terminal-only completion notification policy).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #134
This commit is contained in:
ahmido 2026-02-24 09:30:15 +00:00
parent 9f5c99317b
commit f13a4ce409
66 changed files with 2162 additions and 1069 deletions

View File

@ -58,8 +58,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 110-ops-ux-enforcement: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
- 109-review-pack-export: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12
- 109-review-pack-export: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
- 109-review-pack-export: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -1,11 +1,15 @@
<!--
Sync Impact Report
- Version change: 1.8.2 → 1.9.0
- Version change: 1.9.0 → 1.10.0
- Modified principles:
- Filament UI — Action Surface Contract (NON-NEGOTIABLE) (tightened UX requirements; added layout/view/empty-state rules)
- Operations / Run Observability Standard (clarified as non-negotiable 3-surface contract; added lifecycle, summary, guards, system-run policy)
- Added sections:
- Filament UI — Layout & Information Architecture Standards (UX-001)
- Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
- OperationRun lifecycle is service-owned (OPS-UX-LC-001)
- Summary counts contract (OPS-UX-SUM-001)
- Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
- Scheduled/system runs (OPS-UX-SYS-001)
- Removed sections: None
- Templates requiring updates:
- ✅ .specify/templates/plan-template.md
@ -158,6 +162,72 @@ ### Operations / Run Observability Standard
- Monitoring pages MUST be DB-only at render time (no external calls).
- Start surfaces MUST NOT perform remote work inline; they only: authorize, create/reuse run (dedupe), enqueue work,
confirm + “View run”.
### Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
If a feature creates/reuses `OperationRun`, it MUST use exactly three feedback surfaces — no others:
1) Toast (intent only / queued-only)
- A toast MAY be shown only when the run is accepted/queued (intent feedback).
- The toast MUST use `OperationUxPresenter::queuedToast($operationType)->send()`.
- Feature code MUST NOT craft ad-hoc operation toasts.
- A dedicated dedupe message MUST use the presenter (e.g., `alreadyQueuedToast(...)`), not `Notification::make()`.
2) Progress (active awareness only)
- Live progress MUST exist only in:
- the global active-ops widget, and
- Monitoring → Operation Run Detail.
- These surfaces MUST show only active runs (`queued|running`) and MUST never show terminal runs.
- Determinate progress is allowed ONLY when `summary_counts.total` and `summary_counts.processed` are valid numeric values.
- Determinate progress MUST be clamped to 0100. Otherwise render indeterminate + elapsed time.
- The widget MUST NOT show percentage text (optional `processed/total` is allowed).
3) Terminal DB Notification (audit outcome only)
- Each run MUST emit exactly one persistent terminal DB notification when it becomes terminal.
- Delivery MUST be initiator-only (no tenant-wide fan-out).
- Completion notifications MUST be `OperationRunCompleted` only.
- Feature code MUST NOT send custom completion DB notifications for operations (no `sendToDatabase()` for completion/abort).
Canonical navigation:
- All “View run” links MUST use the canonical helper and MUST point to Monitoring → Operations → Run Detail.
### OperationRun lifecycle is service-owned (OPS-UX-LC-001)
Any change to `OperationRun.status` or `OperationRun.outcome` MUST go through `OperationRunService` (canonical transition method).
This is the only allowed path because it enforces normalization, summary sanitization, idempotency, and terminal notification emission.
Forbidden outside `OperationRunService`:
- `$operationRun->update(['status' => ...])` / `$operationRun->update(['outcome' => ...])`
- `$operationRun->status = ...` / `$operationRun->outcome = ...`
- Query-based updates that transition `status`/`outcome`
Allowed outside the service:
- Updates to `context`, `message`, `reason_code` that do not change `status`/`outcome`.
### Summary counts contract (OPS-UX-SUM-001)
- `operation_runs.summary_counts` is the canonical metrics source for Ops-UX.
- All keys MUST come from `OperationSummaryKeys::all()` (single source of truth).
- Values MUST be flat numeric-only; no nested objects/arrays; no free-text.
- Producers MUST NOT introduce new keys without:
1) updating `OperationSummaryKeys::all()`,
2) updating the spec canonical list,
3) adding/adjusting tests.
### Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
The repo MUST include automated guards (Pest) that fail CI if:
- any direct `OperationRun` status/outcome transition occurs outside `OperationRunService`,
- jobs emit DB notifications for operation completion/abort (`OperationRunCompleted` is the single terminal notification),
- deprecated legacy operation notification classes are referenced again.
These guards MUST fail with actionable output (file + snippet).
### Scheduled/system runs (OPS-UX-SYS-001)
- If a run has no initiator user, no terminal DB notification is emitted (initiator-only policy).
- Outcomes remain auditable via Monitoring → Operations / Run Detail.
- Any tenant-wide alerting MUST go through the Alerts system (not `OperationRun` notifications).
- Active-run dedupe MUST be enforced at DB level (partial unique index/constraint for active states).
- Failures MUST be stored as stable reason codes + sanitized messages; never persist secrets/tokens/PII/raw payload dumps
in failures or notifications.
@ -274,4 +344,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.9.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-19
**Version**: 1.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-23

View File

@ -41,6 +41,11 @@ ## Constitution Check
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
- Ops-UX 3-surface feedback: if `OperationRun` is used, feedback is exactly toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); no queued/running DB notifications
- Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside
- Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only
- Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests

View File

@ -94,6 +94,13 @@ ## Requirements *(mandatory)*
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
- describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules,
- clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring),
- list which regression guard tests are added/updated to keep these rules enforceable in CI.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
- ensure any cross-plane access is deny-as-not-found (404),

View File

@ -14,6 +14,13 @@ # Tasks: [FEATURE NAME]
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints
without an `OperationRun`.
If this feature creates/reuses an `OperationRun`, tasks MUST also include:
- enforcing the Ops-UX 3-surface feedback contract (toast intent-only via `OperationUxPresenter`, progress only in widget + run detail, terminal notification is `OperationRunCompleted` exactly-once, initiator-only),
- ensuring no queued/running DB notifications exist anywhere for operations (no `sendToDatabase()` for queued/running/completion/abort in feature code),
- ensuring `OperationRun.status` / `OperationRun.outcome` transitions happen only via `OperationRunService`,
- ensuring `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only,
- adding/updating Ops-UX regression guards (Pest) that fail CI with actionable output (file + snippet) when these patterns regress,
- clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system).
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
- explicit 404 vs 403 semantics:

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

@ -0,0 +1,37 @@
# Specification Quality Checklist: Ops-UX Enforcement & Cleanup (Enterprise Standard Rollout)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-23
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass.
- Spec includes the mandatory UI Action Matrix and explicitly states “no new screens” while allowing targeted start-surface cleanup.
- Remediation targets are enumerated in the specs “Known Violations” tables; the executable task list is the single source of truth in `tasks.md`.
- Guard tests (FR-012) are specced as static analysis (filesystem scan) with explicit allowlist, so they fail fast with actionable output.

View File

@ -0,0 +1,9 @@
# Contracts: No API Changes
This feature introduces **no new HTTP endpoints**, no new API resources, and no changes to existing request/response contracts.
Scope is limited to internal operation flow behavior:
- OperationRun status/outcome transitions must go through `OperationRunService`.
- Queued/running DB notifications are banned.
- Terminal completion notification is initiator-only and emitted exactly once.

View File

@ -0,0 +1,39 @@
# Phase 1 Design: Data Model (No Schema Changes)
This feature does not introduce schema changes. It enforces consistent usage of existing entities.
## Entity: OperationRun (`operation_runs`)
**Ownership/scoping**:
- Tenant-scoped operational artifact.
- Initiator user is optional (system/scheduled runs).
**Key fields (existing)**:
- `id`
- `workspace_id` / `tenant_id` (scoping)
- `user_id` (initiator; nullable)
- `type` (operation type string)
- `status` (`queued`/`running`/`completed`)
- `outcome` (terminal outcome; nullable until completed)
- `started_at`, `completed_at`
- `summary_counts` (JSON/array of numeric-only whitelisted keys)
- `failure_summary` (sanitized bounded array)
- `context` (additional metadata; mutable)
**Invariants enforced by this feature**:
- All transitions of `status` and `outcome` happen through `OperationRunService::updateRun()`.
- The only operation-related DB notification is the terminal `OperationRunCompleted`, emitted when transitioning into `completed` and only when `user_id` exists.
## Entity: Database Notifications (`notifications`)
**Ownership/scoping**:
- User-scoped records (`notifiable_type=User`), used for persistent notification audit.
**Invariants enforced by this feature**:
- No queued/running state notifications are persisted.
- Exactly one terminal operation completion notification is persisted per OperationRun + initiator.

View File

@ -0,0 +1,143 @@
# Implementation Plan: Ops-UX Enforcement & Cleanup (Enterprise Standard Rollout)
**Branch**: `110-ops-ux-enforcement` | **Date**: 2026-02-23 | **Spec**: [specs/110-ops-ux-enforcement/spec.md](specs/110-ops-ux-enforcement/spec.md)
**Input**: Feature specification from `/specs/110-ops-ux-enforcement/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Enforce the Ops-UX “3-surface contract” by removing non-canonical operation-run status/outcome transitions and banning queued/running DB notifications across in-scope operation flows.
Implementation is split into:
- Cleanup: move all terminal transitions to `OperationRunService`, remove job-level `sendToDatabase()` completion/queued notifications, and delete the legacy `RunStatusChangedNotification`.
- Enforcement: add Pest guard tests (filesystem scans) that fail CI when these patterns reappear.
- Verification: add focused regression tests asserting “exactly one terminal `OperationRunCompleted` notification for initiator; none for system runs; and zero queued/running notifications”.
## Technical Context
**Language/Version**: PHP 8.4.x
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
**Storage**: PostgreSQL (Sail)
**Testing**: Pest v4 (`vendor/bin/sail artisan test --compact`)
**Target Platform**: Docker via Laravel Sail (local); Dokploy (staging/prod)
**Project Type**: Laravel monolith (Filament admin panel)
**Performance Goals**: Guard tests run in < 1s locally; overall targeted test pack < 30s
**Constraints**:
- No schema changes and no new routes.
- Terminal DB notification is initiator-only and emitted exactly once.
- No queued/running DB notifications anywhere (including `OperationRunQueued`).
- Existing RBAC/capability gates for starting operations remain unchanged.
**Scale/Scope**: Tenant-runtime remediation for enumerated violating flows + repo-wide enforcement via guard tests.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
This spec is a cleanup/enforcement layer and does not introduce new screens, Graph calls, or operation types.
- Inventory-first / Read-write separation: PASS (no new inventory/backups semantics; no new write UX)
- Graph contract path: PASS (no Graph call additions)
- Deterministic capabilities: PASS (no capability model changes)
- RBAC-UX / isolation: PASS (no new routes or cross-plane access)
- Run observability: PASS (this spec strengthens dedupe + terminal notifications by removing ad-hoc notifications)
- Automation: PASS (no scheduling behavior changes; only notification/transition handling)
- Data minimization: PASS (removes notification spam; no payload storage changes)
- Badge semantics: PASS (no new badges)
- Filament UI contracts: PASS (no new screens; targeted updates to existing start surfaces only). The spec includes a mandatory UI Action Matrix.
## Project Structure
### Documentation (this feature)
```text
specs/110-ops-ux-enforcement/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
```text
app/
├── Jobs/
├── Notifications/
├── Services/
└── Support/
config/
database/
├── migrations/
└── factories/
resources/
├── views/
└── css/
routes/
tests/
└── Feature/
```
**Structure Decision**: Laravel monolith. Enforcement is implemented as:
- Targeted production code edits under `app/` (jobs/services/notifications).
- Guard + regression tests under `tests/Feature/OpsUx/**`.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None | N/A | N/A |
## Phase 0 — Outline & Research
**Inputs**: [specs/110-ops-ux-enforcement/spec.md](specs/110-ops-ux-enforcement/spec.md), [constitution](.specify/memory/constitution.md)
Research is captured in [specs/110-ops-ux-enforcement/research.md](specs/110-ops-ux-enforcement/research.md) and resolves:
- Guard-test heuristics (regex/scan approach) to keep false positives low.
- Where/how queued DB notifications are emitted today, and how to disable them by default.
- Preferred test strategy for “exactly once terminal notification”.
## Phase 1 — Design & Contracts
Design artifacts are captured in:
- [specs/110-ops-ux-enforcement/data-model.md](specs/110-ops-ux-enforcement/data-model.md) (no schema changes; key entity behavior)
- [specs/110-ops-ux-enforcement/contracts/](specs/110-ops-ux-enforcement/contracts/) (no new API endpoints; documented as “no contract changes”)
- [specs/110-ops-ux-enforcement/quickstart.md](specs/110-ops-ux-enforcement/quickstart.md) (how to run the focused tests)
**Design highlights**:
- The only terminal DB notification is `OperationRunCompleted`, emitted from `OperationRunService` when an initiator exists.
- Guard A scans `app/**/*.php` for `->update([...])` arrays that include `status` and/or `outcome` keys, excluding `OperationRunService`.
- Guard B scans `app/**/*.php` for files that both reference an OperationRun signal and emit DB notifications (`sendToDatabase(` or `->notify(`), with a strict allowlist.
- Guard C ensures the legacy notification class is fully removed.
## Phase 2 — Execution Plan (Implementation)
Work is implemented in small, reviewable steps:
1. **P0 fixes (silent completions)**: replace direct terminal updates with `OperationRunService` transitions in the enumerated services/jobs.
2. **P0 fixes (notification spam)**: remove queued/running and custom completion DB notifications from the enumerated jobs.
3. **P0 fixes (legacy removal)**: delete `RunStatusChangedNotification` and remove any invocation.
4. **Enforcement**: add Pest guard tests (A/B/C) + minimal allowlist.
5. **Verification**: add focused regression tests for the key flows (inventory sync, retention, backup schedule run) proving the “exactly once terminal notification” contract.
**Out of scope**: new UI pages, schema changes, new operation types, Graph contract changes.
## Constitution Check (Post-Phase 1 Re-check)
- PASS: No new routes, no Graph calls, no new Filament screens.
- PASS: Strengthens Run Observability by centralizing terminal notification emission.
- PASS: RBAC-UX/isolation unaffected (no new access paths).

View File

@ -0,0 +1,24 @@
# 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 tests/Feature/OpsUx/Constitution`
## Run the focused regression tests
- `./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`

View File

@ -0,0 +1,89 @@
# Phase 0 Research: Ops-UX Enforcement & Cleanup
This research document resolves implementation uncertainties for the enforcement spec and records key decisions.
## Decision 1 — Implement guards as Pest “architecture tests” (filesystem scan)
**Decision**: Implement Guard A/B/C as Pest tests that scan PHP source files in `app/` (and for Guard C also `tests/`) and fail CI with actionable file + snippet output.
**Rationale**:
- Runs in the same CI pipeline as the rest of the test suite (no new tooling step).
- Enforcement is repo-wide and does not rely on developer discipline or code review memory.
- Can produce highly actionable failure output (file path + snippet) without external dependencies.
**Alternatives considered**:
- PHPStan custom rules: higher precision, but adds configuration + toolchain scope.
- `nikic/php-parser` AST scanning: high precision but adds a dependency (out of bounds per repo constraints).
## Decision 2 — Guard A detection uses tokenizer-assisted scanning (not pure regex)
**Decision**: Implement Guard A using PHPs built-in tokenizer (`token_get_all()`) to detect `->update([...])` call sites and then inspect the array literal for `status` / `outcome` keys.
**Rationale**:
- Pure multi-line regex is brittle with nested arrays, comments, and strings.
- Tokenization gives a dependency-free way to avoid common false positives and to compute an approximate line number.
- The specs intended heuristic (“`->update([...])` block contains status/outcome keys”) maps naturally to tokens.
**Alternatives considered**:
- Regex-only `DOTALL` matching with string/comment skipping (`(*SKIP)(*F)`): simpler, but can still drift across the wrong closing bracket and become noisy.
- Manual char-by-char scanner after a simple “find start” regex: workable, but token-based is typically easier to maintain in PHP.
**Repo evidence**:
- Canonical status/outcome transitions live in `OperationRunService::updateRun()`.
- Violations exist in jobs/services that call `$operationRun->update([... 'status' => ..., 'outcome' => ...])` directly (enumerated in the spec).
## Decision 3 — Guard B flags “OperationRun in scope” + “DB notification emission”
**Decision**: Implement Guard B as a two-signal scan:
- **OperationRun signal**: file contains any of: `use App\\Models\\OperationRun;`, `OperationRun`, `$this->operationRun`, `$operationRun`.
- **DB emission signal**: file contains either `sendToDatabase(` or `->notify(`.
- **Allowlist**: only `app/Services/OperationRunService.php` and `app/Notifications/OperationRunCompleted.php`.
**Rationale**:
- Enforces the constitution: completion notifications are centralized, queued/running DB notifications are banned.
- Intentionally errs on the side of flagging new ad-hoc notification producers.
**Alternatives considered**:
- “DB emission only” = `sendToDatabase(` and ignore `->notify(`: lower noise, but would miss notify-based DB notifications.
- Restrict scan to `app/Jobs/**` and `app/Services/**`: lower noise, but spec requires `app/**/*.php` for defense-in-depth.
**Repo evidence (high-level)**:
- `OperationRunService` currently emits both `OperationRunCompleted` and `OperationRunQueued` via `->notify(...)`.
- There are multiple job files with `->sendToDatabase(...)` in code paths that also handle an OperationRun.
- There are Filament resource actions that both dispatch jobs with `$operationRun` and call `->sendToDatabase(...)` for queued feedback. These are queued DB notifications and conflict with FR-004.
## Decision 4 — Ban queued DB notifications by changing service defaults
**Decision**: Change `OperationRunService::dispatchOrFail(..., bool $emitQueuedNotification = true)` (and any upstream helper that takes `emitQueuedNotification`) so the default is `false`, and ensure no call site opts back into queued DB notifications.
**Rationale**:
- Matches FR-004 / FR-012b: queued/running DB notifications are forbidden repo-wide.
- Prevents new call sites from accidentally enabling queued DB notifications by omission.
**Alternatives considered**:
- Delete `OperationRunQueued` entirely: higher churn; leaving the class unused is acceptable as long as its never emitted.
- Keep default `true` but require opt-out everywhere: violates FR-012b and is easy to regress.
## Decision 5 — “Exactly once terminal notification” relies on `previousStatus` guard
**Decision**: Rely on `OperationRunService::updateRun()` behavior that emits `OperationRunCompleted` only when transitioning into `Completed` from a non-completed status.
**Rationale**:
- Centralizes “exactly once” semantics in a single place.
- Keeps jobs/services free of notification-specific logic.
**Alternatives considered**:
- Add additional DB-level dedupe: unnecessary if the service is the only producer and transitions are canonical.

View File

@ -0,0 +1,274 @@
# Feature Specification: Ops-UX Enforcement & Cleanup (Enterprise Standard Rollout)
**Feature Branch**: `110-ops-ux-enforcement`
**Created**: 2026-02-23
**Status**: Draft
**Depends On**: `specs/055-ops-ux-rollout/spec.md`
## Clarifications
### Session 2026-02-23
- Q: For Guard A, what detection strategy should we use to catch forbidden status/outcome transitions while allowing context-only updates? → A: Scan `app/` for `->update([...])` blocks that include `status` or `outcome` keys (same call block, multi-line), excluding `app/Services/OperationRunService.php`.
- Q: Should queued/running DB notifications be allowed (e.g., `OperationRunQueued`) or is the DB notification surface terminal-only? → A: Ban queued/running DB notifications entirely (including `OperationRunQueued`); the only DB notification for operations is the terminal `OperationRunCompleted` notification.
- Q: For Guard B (DB-notification guard), what scanning scope/heuristic should we use? → A: Scan `app/**/*.php` and fail when a file both references an OperationRun (import or variable/property usage) and emits any database notification (`sendToDatabase(` or `->notify(`), except for an explicit allowlist.
## Spec Scope Fields *(mandatory)*
- **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.
*Canonical-view fields not applicable — no new views introduced.*
## User Scenarios & Testing *(mandatory)*
### User Story 1 — No Silent Completions (Priority: P1)
As a tenant admin, when I trigger any tracked operation (Inventory Sync, Retention, Backup Schedule Run), I always receive exactly one terminal DB notification upon completion, so I can audit the outcome without checking the Monitoring hub manually.
**Why this priority**: Silent completions break auditability — the core promise of the Ops-UX system. Missing terminal notifications mean admins have no persistent outcome record outside the Monitoring hub.
**Independent Test**: Trigger (or simulate) an inventory sync run to terminal state and assert exactly one `OperationRunCompleted` DB notification exists for the initiator.
**Acceptance Scenarios**:
1. **Given** an `inventory_sync` OperationRun with an initiator, **When** `InventorySyncService` transitions it to a terminal outcome, **Then** exactly one `OperationRunCompleted` DB notification is persisted for the initiator user.
2. **Given** an `apply_backup_retention` OperationRun with an initiator, **When** `ApplyBackupScheduleRetentionJob` completes (success or failure), **Then** exactly one `OperationRunCompleted` DB notification is persisted for the initiator user.
3. **Given** an OperationRun with **no initiator** (system/scheduled run), **When** the run transitions to terminal, **Then** zero DB notifications are emitted.
---
### User Story 2 — No Notification Spam (Priority: P1)
As a tenant admin, I never receive duplicate completion DB notifications for a single run, and I never receive queued/running state DB notifications for any operation.
**Why this priority**: Notification spam erodes trust in the notification surface. Even one duplicate makes admins ignore notifications — defeating the entire audit layer.
**Independent Test**: Simulate a `RunBackupScheduleJob` to completion and assert zero queued/running DB notifications exist, and exactly one terminal DB notification.
**Acceptance Scenarios**:
1. **Given** a backup schedule run job enqueued and completed, **When** all job code paths execute, **Then** zero queued/running DB notifications are persisted, and exactly one terminal `OperationRunCompleted` exists for the initiator.
2. **Given** a `BulkPolicyExportJob` that reaches terminal state via any path (success / abort / circuit-break), **Then** exactly one terminal `OperationRunCompleted` DB notification exists and zero notifications from job-level `sendToDatabase()` calls.
3. **Given** any bulk job that previously sent custom completion DB notifications (`BulkRestoreRunForceDeleteJob`, `AddPoliciesToBackupSetJob`, `RemovePoliciesFromBackupSetJob`), **When** the job completes, **Then** no job-level DB notifications are emitted.
---
### User Story 3 — Legacy Notification Removed (Priority: P1)
As a tenant admin running a restore operation, my completion feedback comes exclusively from the canonical `OperationRunCompleted` notification, not from a legacy `RunStatusChangedNotification` with inconsistent copy or link behavior.
**Why this priority**: The legacy class is an out-of-system notification that bypasses canonical delivery, creating inconsistent UX copy and a second notification channel that cannot be centrally controlled.
**Independent Test**: Confirm `RunStatusChangedNotification` class does not exist in `app/` and `ExecuteRestoreRunJob` no longer references it. Restore run completion produces exactly one `OperationRunCompleted`.
**Acceptance Scenarios**:
1. **Given** `ExecuteRestoreRunJob` completes a restore run to terminal state, **Then** no `RunStatusChangedNotification` is dispatched, and exactly one `OperationRunCompleted` DB notification is persisted for the initiator.
2. **Given** a developer searches `app/` and `tests/` for `RunStatusChangedNotification`, **Then** no results are found (class deleted, all references removed).
---
### User Story 4 — Regression Guards Enforce the Constitution (Priority: P1)
As a developer working on the repo, if I accidentally introduce a direct `$operationRun->update(['status' => ...])` outside `OperationRunService`, a CI guard test immediately fails with a clear file + snippet report so I know exactly what to fix.
**Why this priority**: Without automated guards, the enforcement degrades over time as new features are added. Guards are the only scalable way to maintain the constitution without manual code review on every PR.
**Independent Test**: Introduce a synthetic violation in a temp file, run the guard test, confirm it fails with actionable output. Remove the violation, confirm the test passes.
**Acceptance Scenarios**:
1. **Given** the codebase contains a direct `$operationRun->update(['status' => ...])` outside `OperationRunService`, **When** the guard test runs, **Then** it fails and outputs the violating file path and snippet.
2. **Given** a job file contains both an OperationRun reference and a `sendToDatabase()` call (not on the allowlist), **When** the DB-notification guard test runs, **Then** it fails with the file path.
3. **Given** any file in `app/` or `tests/` references `RunStatusChangedNotification`, **When** the legacy-class guard test runs, **Then** it fails.
4. **Given** the codebase has no violations, **When** all guard tests run, **Then** all pass green.
---
### User Story 5 — Canonical "Already Queued" Toast (Priority: P2)
As a tenant admin triggering an operation that is already queued, I receive a consistent, canonical "already queued" toast message rather than ad-hoc copy from individual feature components, so the experience is uniform across all operation types.
**Why this priority**: P2 polish — non-blocking, but addresses the last remaining ad-hoc UX copy point identified in the audit.
**Independent Test**: Trigger the "already queued" dedup path in `BackupSetPolicyPickerTable` and assert the toast uses the canonical presenter output.
**Acceptance Scenarios**:
1. **Given** an operation is already queued, **When** a user attempts to queue it again via the policy picker, **Then** a toast is shown using `OperationUxPresenter::alreadyQueuedToast(...)` with canonical copy.
---
### Edge Cases
- What happens when a job fails with an unhandled exception — does the OperationRun get stuck in a non-terminal state? *(Assumption: existing job `failed()` callback or `finally` block already transitions via service; confirm during implementation per-file.)*
- What happens when `OperationRunService` itself throws during terminal transition? *(Assumption: run stays non-terminal; pre-existing behavior outside scope of this spec.)*
- What if an OperationRun has no initiator and a job previously sent a DB notification — will removing that path cause silence? *(Confirmed acceptable: system runs are auditable via Monitoring hub only.)*
## Requirements *(mandatory)*
**Constitution alignment — OPS-UX-001**: This spec enforces the Ops-UX 3-surface contract. All status/outcome transitions must be routed through `OperationRunService`. Terminal DB notifications are sent exclusively via `OperationRunCompleted` from within the service. No job or feature code may send its own completion DB notification.
**Constitution alignment — Filament scope**: This spec introduces **no new Filament screens**. It DOES include targeted updates to existing Filament start surfaces (Resources/Pages) to remove queued/running DB notifications and ensure start-surface feedback is toast-only.
**UI Action Matrix (mandatory)**
| Surface | User action | Authorization | Start surface feedback | Progress surface | Terminal surface |
|---------|-------------|---------------|------------------------|------------------|-----------------|
| `PolicyResource` (tenant context) | Trigger an operation that creates/reuses an OperationRun | Existing capability checks (unchanged) | Toast-only (no DB notification) + optional “View run” link | Monitoring → Operations + run detail | Exactly one `OperationRunCompleted` DB notification to initiator (service-owned) |
| `PolicyVersionResource` (tenant context) | Trigger an operation that creates/reuses an OperationRun | Existing capability checks (unchanged) | Toast-only (no DB notification) + optional “View run” link | Monitoring → Operations + run detail | Exactly one `OperationRunCompleted` DB notification to initiator (service-owned) |
| `BackupScheduleResource` (tenant context) | Run / queue backup schedule operation | Existing capability checks (unchanged) | Toast-only (no DB notification) + optional “View run” link | Monitoring → Operations + run detail | Exactly one `OperationRunCompleted` DB notification to initiator (service-owned) |
| `TenantResource` (tenant context) | Trigger an operation that creates/reuses an OperationRun | Existing capability checks (unchanged) | Toast-only (no DB notification) + optional “View run” link | Monitoring → Operations + run detail | Exactly one `OperationRunCompleted` DB notification to initiator (service-owned) |
| `EntraGroupResource``ListEntraGroups` (tenant context) | Start manual sync operation | Existing capability checks (unchanged) | Toast-only (no DB notification) + optional “View run” link | Monitoring → Operations + run detail | Exactly one `OperationRunCompleted` DB notification to initiator (service-owned) |
| Scheduled/system operations | Jobs run without an initiator | Existing schedule/lock semantics (unchanged) | N/A | Monitoring → Operations + run detail | No DB notification (initiator is null); Monitoring remains canonical |
**Constitution alignment — No new Graph calls / OperationRun types**: This spec modifies existing operation flows only; it does not introduce new operation types or Graph calls.
**Constitution alignment — BADGE-001**: No new status badge values introduced.
### Functional Requirements
- **FR-001**: All OperationRun `status` and `outcome` field transitions MUST go through `OperationRunService` canonical transition methods. Direct `$operationRun->update(['status' => ...])`, `$operationRun->status = ...`, `$operationRun->outcome = ...`, or bulk query updates on status/outcome are forbidden outside `OperationRunService`.
- **FR-002**: `OperationRunService` MUST emit exactly one `OperationRunCompleted` DB notification to the initiator when transitioning a run to a terminal outcome (when an initiator exists).
- **FR-003**: No job or service code outside `OperationRunService` MAY emit a DB notification representing operation completion, abort, or terminal state.
- **FR-004**: No code anywhere MAY emit a DB notification for queued or running operation states. This includes `OperationRunQueued` and any other “queued” / “started” / “running” database notifications.
- **FR-005**: `RunStatusChangedNotification` MUST be deleted. No references to it may remain in `app/` or `tests/`.
- **FR-006**: `InventorySyncService` MUST transition to terminal state exclusively via `OperationRunService`, not via direct model updates.
- **FR-007**: `ApplyBackupScheduleRetentionJob` MUST transition to terminal state exclusively via `OperationRunService`.
- **FR-008**: `TenantpilotBackfillWorkspaceIds` console command MUST use the canonical transition if it transitions OperationRun status (initiator may be null; no DB notification emitted in that case).
- **FR-009**: `RunBackupScheduleJob` MUST NOT emit any queued or completion DB notifications; outcome notification is handled by `OperationRunService` terminal transition.
- **FR-010**: `BulkPolicyExportJob`, `BulkRestoreRunForceDeleteJob`, `BulkRestoreRunRestoreJob`, `BulkPolicyUnignoreJob`, `AddPoliciesToBackupSetJob`, and `RemovePoliciesFromBackupSetJob` MUST NOT call `sendToDatabase()` for operation completion, abort, or queued/running feedback.
- **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 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.
- **FR-013** *(P2)*: `OperationUxPresenter` MUST expose an `alreadyQueuedToast(...)` static helper returning canonical copy + duration (+ optional "View run" action).
- **FR-014** *(P2)*: `BackupSetPolicyPickerTable` dedup toast MUST use `OperationUxPresenter::alreadyQueuedToast(...)`.
## Scope (Known Violations — Remediation Targets)
### Status transition bypass (direct model update — silent completion)
| File | Violation | Priority |
|------|-----------|----------|
| `app/Services/Inventory/InventorySyncService.php` | Direct `update([status/outcome])` — silent completion | P0 |
| `app/Jobs/ApplyBackupScheduleRetentionJob.php` | Direct `update([...])` — silent completion | P0 |
| `app/Console/Commands/TenantpilotBackfillWorkspaceIds.php` | Direct status update | P1 |
### Job-level DB notifications (duplicates / queued spam)
| File | Violation | Priority |
|------|-----------|----------|
| `app/Jobs/RunBackupScheduleJob.php` | Queued DB notification + custom finished notification | P0 |
| `app/Jobs/BulkPolicyExportJob.php` | Multiple `sendToDatabase()` paths | P1 |
| `app/Jobs/BulkRestoreRunForceDeleteJob.php` | Multiple `sendToDatabase()` paths | P1 |
| `app/Jobs/BulkRestoreRunRestoreJob.php` | Multiple `sendToDatabase()` paths | P1 |
| `app/Jobs/BulkPolicyUnignoreJob.php` | Multiple `sendToDatabase()` paths | P1 |
| `app/Jobs/AddPoliciesToBackupSetJob.php` | Custom completion DB notifications | P1 |
| `app/Jobs/RemovePoliciesFromBackupSetJob.php` | Custom completion DB notifications | P1 |
### Start-surface queued DB notifications (forbidden)
| File | Violation | Priority |
|------|-----------|----------|
| `app/Filament/Resources/PolicyResource.php` | Queued/running DB notification emitted from a start surface | P1 |
| `app/Filament/Resources/PolicyVersionResource.php` | Queued/running DB notification emitted from a start surface | P1 |
| `app/Filament/Resources/BackupScheduleResource.php` | Queued/running DB notification emitted from a start surface | P1 |
| `app/Filament/Resources/TenantResource.php` | Queued/running DB notification emitted from a start surface | P1 |
| `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php` | Queued/running DB notification emitted from a start surface | P1 |
### Legacy notification outside system
| File | Violation | Priority |
|------|-----------|----------|
| `app/Jobs/ExecuteRestoreRunJob.php` | References `RunStatusChangedNotification` | P0 |
| `app/Notifications/RunStatusChangedNotification.php` | Class to delete | P0 |
### Optional polish (P2)
| File | Violation | Priority |
|------|-----------|----------|
| `app/Livewire/BackupSetPolicyPickerTable.php` | Ad-hoc "already queued" toast (non-canonical copy) | P2 |
---
## Execution Plan Reference
The executable work breakdown for this spec lives in [specs/110-ops-ux-enforcement/tasks.md](specs/110-ops-ux-enforcement/tasks.md). The spec intentionally avoids duplicating a task list to prevent drift.
## Testing Plan (Pest)
### Guard tests (mandatory — CI enforcement layer)
- `tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php`
- `tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php`
- `tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php`
### Regression tests (mandatory for P0 fixes)
- `tests/Feature/OpsUx/Regression/InventorySyncTerminalNotificationTest.php` — exactly one `OperationRunCompleted` for initiator; none for system run
- `tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php` — exactly one `OperationRunCompleted`; no direct model transitions
- `tests/Feature/OpsUx/Regression/BackupScheduleRunNotificationTest.php` — zero queued DB notifications; exactly one terminal notification
- `tests/Feature/OpsUx/Regression/BulkJobCircuitBreakerTest.php` — circuit-breaker / abort path yields exactly one terminal `OperationRunCompleted` and zero job-level DB notifications (representative bulk job)
### Recommended test locations
- Guards: `tests/Feature/OpsUx/Constitution/`
- Regressions: `tests/Feature/OpsUx/Regression/`
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Every tracked operation with an initiator produces exactly one persistent DB notification upon terminal completion — zero operations complete silently.
- **SC-002**: Zero queued/running DB notifications are emitted across all operation flows.
- **SC-003**: Zero duplicate completion DB notifications for any single operation run across all exit paths (success, failure, partial, circuit-break).
- **SC-004**: `RunStatusChangedNotification` class is fully deleted — zero references remain in the codebase.
- **SC-005**: All three CI guard tests pass green on a clean codebase and fail with actionable output when a synthetic violation is introduced.
- **SC-006**: All existing OpsUx test suites and related test files pass without regression after changes.
- **SC-007** *(P2)*: All "already queued" dedup feedback paths use identical canonical copy, verifiable by a single source-of-truth presenter call.
---
## Definition of Done (DoD)
- [ ] All in-scope files no longer directly update status/outcome outside `OperationRunService`
- [ ] All in-scope jobs no longer emit completion/abort/queued DB notifications
- [ ] `RunStatusChangedNotification` deleted; zero references in `app/` and `tests/`
- [ ] Three guard tests exist and pass in CI (and fail with actionable output on synthetic violations)
- [ ] All OpsUx regression tests pass
- [ ] Full test suite green (no regressions)
- [ ] Pint formatting clean for all touched files
- [ ] `AGENTS.md` / constitution updated to reference the non-negotiable Ops-UX rule if not already present from 055 follow-up
---
## Assumptions
1. `OperationRunService` already exposes a canonical terminal transition method (from Spec 055); this spec calls it, not redeclares it.
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 (`T026`/`T027`) are optional and do not gate release of P0/P1 work.
---
## Rollout / PR Slicing
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

@ -0,0 +1,225 @@
---
description: "Executable task list for Ops-UX Enforcement & Cleanup"
---
# Tasks: Ops-UX Enforcement & Cleanup (Enterprise Standard Rollout)
**Input**: Design documents from `/specs/110-ops-ux-enforcement/`
- plan.md: [specs/110-ops-ux-enforcement/plan.md](specs/110-ops-ux-enforcement/plan.md)
- spec.md: [specs/110-ops-ux-enforcement/spec.md](specs/110-ops-ux-enforcement/spec.md)
- research.md: [specs/110-ops-ux-enforcement/research.md](specs/110-ops-ux-enforcement/research.md)
- data-model.md: [specs/110-ops-ux-enforcement/data-model.md](specs/110-ops-ux-enforcement/data-model.md)
- contracts/: [specs/110-ops-ux-enforcement/contracts/](specs/110-ops-ux-enforcement/contracts/)
**Tests**: REQUIRED (Pest) — both guard tests and focused regressions.
## Phase 1: Setup (Shared Test Infrastructure)
**Purpose**: Create minimal shared helpers for guard tests and keep failure output consistent.
- [X] T001 [P] Add source scanning helper in tests/Support/OpsUx/SourceFileScanner.php
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Cross-cutting invariants that must be true before user story work can be considered “done”.
- [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.
---
## Phase 3: User Story 1 — No Silent Completions (Priority: P1) 🎯 MVP
**Goal**: Terminal transitions always go through `OperationRunService`, producing exactly one terminal `OperationRunCompleted` notification for initiators.
**Independent Test**: Inventory sync + retention flow transitions to terminal and persists exactly one `OperationRunCompleted` notification for the initiator; system runs persist none.
### Tests (write first)
- [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
- [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.
---
## Phase 4: User Story 2 — No Notification Spam (Priority: P1)
**Goal**: Remove job-level queued/completion DB notifications and eliminate queued DB notifications from start surfaces.
**Independent Test**: Backup schedule run + representative bulk flow complete with exactly one terminal `OperationRunCompleted` and zero queued/running DB notifications.
### Tests (write first)
- [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)
- [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)
- [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.
---
## Phase 5: User Story 3 — Legacy Notification Removed (Priority: P1)
**Goal**: Remove the out-of-system `RunStatusChangedNotification` and rely exclusively on canonical terminal `OperationRunCompleted`.
**Independent Test**: Restore run completion produces exactly one `OperationRunCompleted` notification and there are zero references to `RunStatusChangedNotification` in `app/` and `tests/`.
### Tests (write first)
- [X] T020 [P] [US3] Add restore run terminal notification regression test in tests/Feature/OpsUx/Regression/RestoreRunTerminalNotificationTest.php
### Implementation
- [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.
---
## Phase 6: User Story 4 — Regression Guards Enforce the Constitution (Priority: P1)
**Goal**: CI guard tests fail fast when forbidden patterns reappear.
**Independent Test**: Guards fail with actionable output on a synthetic violation and pass on a clean codebase.
### Guard tests
- [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.
---
## Phase 7: User Story 5 — Canonical "Already Queued" Toast (Priority: P2)
**Goal**: Dedup “already queued” messaging is canonical and consistent.
**Independent Test**: Trigger dedup path and confirm toast uses `OperationUxPresenter::alreadyQueuedToast(...)`.
- [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**: Final documentation alignment plus execution validation (guards, regressions, full suite, formatting).
- [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
---
## Dependencies & Execution Order
### User Story Completion Order
- US1 → US2 → US3 → US4 → US5
Rationale:
- US1US3 remove known violations so the guard suite (US4) can pass on a clean codebase.
- US5 is optional polish and can land after enforcement is stable.
### Dependency Graph
```text
Phase 1 (Setup) ─┬─> Phase 2 (Foundational) ─┬─> US1 ─┬─> US2 ─┬─> US3 ─┬─> US4 ─┬─> US5
│ │ │ │ │ └─> Polish
└───────────────────────────┴────────┴────────┴────────┴───────────────
```
## Parallel Execution Examples
### US1 (tests + implementation)
```bash
T004: tests/Feature/OpsUx/Regression/InventorySyncTerminalNotificationTest.php
T005: tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php
```
### US2 (jobs + start surfaces)
```bash
T011: app/Jobs/RunBackupScheduleJob.php
T012: app/Jobs/BulkPolicyExportJob.php
T013: app/Jobs/BulkRestoreRunForceDeleteJob.php
T014: app/Jobs/AddPoliciesToBackupSetJob.php
T015: app/Jobs/RemovePoliciesFromBackupSetJob.php
T016T019: app/Filament/Resources/*Resource.php
```
### US4 (guards)
```bash
T023: tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php
T024: tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php
T025: tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php
```
## Implementation Strategy
### MVP Scope (recommended)
- Foundational (Phase 2) + US1 only.
This yields the highest-value guarantee quickly: “no silent completions” and exactly-once terminal notifications for the most critical flows.
### Incremental Delivery
1. Foundational → US1 → validate
2. US2 → validate
3. US3 → validate
4. US4 guards → enforce
5. US5 polish

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