TenantAtlas/app/Jobs/RemovePoliciesFromBackupSetJob.php
ahmido 3030dd9af2 054-unify-runs-suitewide (#63)
Summary

Kurz: Implementiert Feature 054 — canonical OperationRun-flow, Monitoring UI, dispatch-safety, notifications, dedupe, plus small UX safety clarifications (RBAC group search delegated; Restore group mapping DB-only).
What Changed

Core service: OperationRun lifecycle, dedupe and dispatch helpers — OperationRunService.php.
Model + migration: OperationRun model and migration — OperationRun.php, 2026_01_16_180642_create_operation_runs_table.php.
Notifications: queued + terminal DB notifications (initiator-only) — OperationRunQueued.php, OperationRunCompleted.php.
Monitoring UI: Filament list/detail + Livewire pieces (DB-only render) — OperationRunResource.php and related pages/views.
Start surfaces / Jobs: instrumented start surfaces, job middleware, and job updates to use canonical runs — multiple app/Jobs/* and app/Filament/* updates (see tests for full coverage).
RBAC + Restore UX clarifications: RBAC group search is delegated-Graph-based and disabled without delegated token; Restore group mapping remains DB-only (directory cache) and helper text always visible — TenantResource.php, RestoreRunResource.php.
Specs / Constitution: updated spec & quickstart and added one-line constitution guideline about Graph usage:
spec.md
quickstart.md
constitution.md
Tests & Verification

Unit / Feature tests added/updated for run lifecycle, notifications, idempotency, and UI guards: see tests/Feature/* (notably OperationRunServiceTest, MonitoringOperationsTest, OperationRunNotificationTest, and various Filament feature tests).
Full test run locally: ./vendor/bin/sail artisan test → 587 passed, 5 skipped.
Migrations

Adds create_operation_runs_table migration; run php artisan migrate in staging after review.
Notes / Rationale

Monitoring pages are explicitly DB-only at render time (no Graph calls). Start surfaces enqueue work only and return a “View run” link.
Delegated Graph access is used only for explicit user actions (RBAC group search); restore mapping intentionally uses cached DB data only to avoid render-time Graph calls.
Dispatch wrapper marks runs failed immediately if background dispatch throws synchronously to avoid misleading “queued” states.
Upgrade / Deploy Considerations

Run migrations: ./vendor/bin/sail artisan migrate.
Background workers should be running to process queued jobs (recommended to monitor queue health during rollout).
No secret or token persistence changes.
PR checklist

 Tests updated/added for changed behavior
 Specs updated: 054-unify-runs-suitewide docs + quickstart
 Constitution note added (.specify)
 Pint formatting applied

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #63
2026-01-17 22:25:00 +00:00

302 lines
9.6 KiB
PHP

<?php
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class RemovePoliciesFromBackupSetJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* @param array<int,int> $backupItemIds
*/
public function __construct(
public int $backupSetId,
public array $backupItemIds,
public int $initiatorUserId,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(
AuditLogger $auditLogger,
BulkOperationService $bulkOperationService,
): void {
$backupSet = BackupSet::query()->with(['tenant'])->find($this->backupSetId);
if (! $backupSet instanceof BackupSet) {
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->updateRun(
$this->operationRun,
'completed',
'failed',
['backup_set_id' => $this->backupSetId],
[['code' => 'backup_set.not_found', 'message' => 'Backup set not found.']]
);
}
return;
}
$tenant = $backupSet->tenant;
$initiator = User::query()->find($this->initiatorUserId);
$requestedIds = collect($this->backupItemIds)
->map(fn (mixed $value): int => (int) $value)
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
$requestedCount = count($requestedIds);
$failures = [];
try {
/** @var \Illuminate\Database\Eloquent\Collection<int, BackupItem> $items */
$items = BackupItem::query()
->where('backup_set_id', $backupSet->getKey())
->whereIn('id', $requestedIds)
->get();
$foundIds = $items->pluck('id')->map(fn (mixed $value): int => (int) $value)->all();
$missingIds = array_values(array_diff($requestedIds, $foundIds));
foreach ($missingIds as $missingId) {
$failures[] = [
'code' => 'backup_item.not_found',
'message' => $bulkOperationService->sanitizeFailureReason("Backup item {$missingId} not found (already removed?)."),
];
}
$removed = 0;
$policyIds = [];
$policyIdentifiers = [];
foreach ($items as $item) {
$item->delete();
$removed++;
if ($item->policy_id) {
$policyIds[] = (int) $item->policy_id;
}
if ($item->policy_identifier) {
$policyIdentifiers[] = (string) $item->policy_identifier;
}
}
$backupSet->update([
'item_count' => $backupSet->items()->count(),
]);
if ($tenant instanceof Tenant) {
$auditLogger->log(
tenant: $tenant,
action: 'backup.items_removed',
resourceType: 'backup_set',
resourceId: (string) $backupSet->getKey(),
status: 'success',
context: [
'metadata' => [
'removed_count' => $removed,
'requested_count' => $requestedCount,
'missing_count' => count($missingIds),
'policy_ids' => array_values(array_unique($policyIds)),
'policy_identifiers' => array_values(array_unique($policyIdentifiers)),
'backup_set_id' => (int) $backupSet->getKey(),
'initiator_user_id' => $initiator?->getKey(),
],
],
actorId: $initiator?->getKey(),
);
}
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$outcome = 'succeeded';
if ($removed === 0) {
$outcome = 'failed';
} elseif ($failures !== []) {
$outcome = 'partially_succeeded';
}
$opService->updateRun(
$this->operationRun,
'completed',
$outcome,
[
'backup_set_id' => (int) $backupSet->getKey(),
'requested' => $requestedCount,
'removed' => $removed,
'missing' => count($missingIds),
'remaining' => (int) $backupSet->item_count,
],
$failures,
);
}
$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(
tenant: $tenant,
action: 'backup.items_removed',
resourceType: 'backup_set',
resourceId: (string) $backupSet->getKey(),
status: 'failed',
context: [
'metadata' => [
'requested_count' => $requestedCount,
'backup_set_id' => (int) $backupSet->getKey(),
],
],
actorId: $initiator?->getKey(),
);
}
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opService->failRun($this->operationRun, $throwable);
}
$this->notifyFailed(
initiator: $initiator,
tenant: $tenant instanceof Tenant ? $tenant : null,
reason: $bulkOperationService->sanitizeFailureReason($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();
}
}