$policyIds */ public function createBackupSet( Tenant $tenant, array $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, ): BackupSet { $this->assertActiveTenant($tenant); $policies = Policy::query() ->where('tenant_id', $tenant->id) ->whereIn('id', $policyIds) ->get(); $backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name) { $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => $name ?? CarbonImmutable::now()->format('Y-m-d H:i:s').' backup', 'created_by' => $actorEmail, 'status' => 'running', 'metadata' => [], ]); $failures = []; $itemsCreated = 0; foreach ($policies as $policy) { [$item, $failure] = $this->snapshotPolicy($tenant, $backupSet, $policy, $actorEmail); if ($failure !== null) { $failures[] = $failure; continue; } if ($item !== null) { $itemsCreated++; } } $status = $this->resolveStatus($itemsCreated, $failures); $backupSet->update([ 'status' => $status, 'item_count' => $itemsCreated, 'completed_at' => CarbonImmutable::now(), 'metadata' => ['failures' => $failures], ]); return $backupSet->refresh(); }); $this->auditLogger->log( tenant: $tenant, action: 'backup.created', context: [ 'metadata' => [ 'backup_set_id' => $backupSet->id, 'item_count' => $backupSet->item_count, 'status' => $backupSet->status, ], ], actorEmail: $actorEmail, actorName: $actorName, resourceType: 'backup_set', resourceId: (string) $backupSet->id, status: $backupSet->status === 'completed' ? 'success' : 'partial' ); return $backupSet; } /** * Add snapshots for additional policies to an existing backup set. * * @param array $policyIds */ public function addPoliciesToSet( Tenant $tenant, BackupSet $backupSet, array $policyIds, ?string $actorEmail = null, ?string $actorName = null, ): BackupSet { $this->assertActiveTenant($tenant); if ($backupSet->trashed() || $backupSet->tenant_id !== $tenant->id) { throw new \RuntimeException('Backup set is archived or does not belong to the current tenant.'); } $existingPolicyIds = $backupSet->items()->withTrashed()->pluck('policy_id')->filter()->all(); $policyIds = array_values(array_diff($policyIds, $existingPolicyIds)); if (empty($policyIds)) { return $backupSet->refresh(); } $policies = Policy::query() ->where('tenant_id', $tenant->id) ->whereIn('id', $policyIds) ->get(); $metadata = $backupSet->metadata ?? []; $failures = $metadata['failures'] ?? []; $itemsCreated = 0; foreach ($policies as $policy) { [$item, $failure] = $this->snapshotPolicy($tenant, $backupSet, $policy, $actorEmail); if ($failure !== null) { $failures[] = $failure; continue; } if ($item !== null) { $itemsCreated++; } } $status = $this->resolveStatus($itemsCreated, $failures); $backupSet->update([ 'status' => $status, 'item_count' => $backupSet->items()->count(), 'completed_at' => CarbonImmutable::now(), 'metadata' => ['failures' => $failures], ]); $this->auditLogger->log( tenant: $tenant, action: 'backup.items_added', context: [ 'metadata' => [ 'backup_set_id' => $backupSet->id, 'added_count' => $itemsCreated, 'status' => $status, ], ], actorEmail: $actorEmail, actorName: $actorName, resourceType: 'backup_set', resourceId: (string) $backupSet->id, status: $status === 'completed' ? 'success' : 'partial' ); return $backupSet->refresh(); } private function resolveStatus(int $itemsCreated, array $failures): string { return match (true) { $itemsCreated === 0 && count($failures) > 0 => 'failed', count($failures) > 0 => 'partial', default => 'completed', }; } /** * @return array{0:?BackupItem,1:?array{policy_id:int,reason:string,status:int|string|null}} */ private function snapshotPolicy(Tenant $tenant, BackupSet $backupSet, Policy $policy, ?string $actorEmail = null): array { $snapshot = $this->snapshotService->fetch($tenant, $policy, $actorEmail); if (isset($snapshot['failure'])) { return [null, $snapshot['failure']]; } $payload = $snapshot['payload']; $metadata = $snapshot['metadata'] ?? []; $metadataWarnings = $snapshot['warnings'] ?? []; $validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []); $metadataWarnings = array_merge($metadataWarnings, $validation['warnings']); $odataWarning = BackupItem::odataTypeWarning(is_array($payload) ? $payload : [], $policy->policy_type, $policy->platform); if ($odataWarning) { $metadataWarnings[] = $odataWarning; } if (! empty($metadataWarnings)) { $metadata['warnings'] = array_values(array_unique($metadataWarnings)); } $backupItem = BackupItem::create([ 'tenant_id' => $tenant->id, 'backup_set_id' => $backupSet->id, 'policy_id' => $policy->id, 'policy_identifier' => $policy->external_id, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, 'payload' => $payload, 'metadata' => $metadata, ]); $this->versionService->captureVersion( policy: $policy, payload: $payload, createdBy: $actorEmail, metadata: [ 'source' => 'backup', 'backup_set_id' => $backupSet->id, 'backup_item_id' => $backupItem->id, ] ); return [$backupItem, null]; } private function assertActiveTenant(Tenant $tenant): void { if (! $tenant->isActive()) { throw new \RuntimeException('Tenant is archived or inactive.'); } } }