TenantAtlas/app/Jobs/RemovePoliciesFromBackupSetJob.php
ahmido f13a4ce409 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
2026-02-24 09:30:15 +00:00

209 lines
7.0 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\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\OpsUx\RunFailureSanitizer;
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,
): 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',
['failed' => 1],
[['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' => RunFailureSanitizer::sanitizeMessage("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);
$this->operationRun->update([
'context' => array_merge($this->operationRun->context ?? [], [
'backup_set_id' => (int) $backupSet->getKey(),
'requested_count' => $requestedCount,
'removed_count' => $removed,
'missing_count' => count($missingIds),
'remaining_count' => (int) $backupSet->item_count,
]),
]);
$outcome = 'succeeded';
if ($removed === 0) {
$outcome = 'failed';
} elseif ($failures !== []) {
$outcome = 'partially_succeeded';
}
$opService->updateRun(
$this->operationRun,
'completed',
$outcome,
[
'total' => $requestedCount,
'processed' => $requestedCount,
'succeeded' => $removed,
'failed' => count($missingIds),
'deleted' => $removed,
'items' => $requestedCount,
],
$failures,
);
}
} 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);
}
throw $throwable;
}
}
}