feat/032-backup-scheduling-mvp #36

Merged
ahmido merged 14 commits from feat/032-backup-scheduling-mvp into dev 2026-01-07 01:12:13 +00:00
16 changed files with 1144 additions and 166 deletions

View File

@ -35,6 +35,13 @@ ## Bulk operations (Feature 005)
- Destructive operations require type-to-confirm at higher thresholds (e.g. `DELETE`).
- Long-running bulk ops are queued; the bottom-right progress widget polls for active runs.
### Troubleshooting
- **Progress stuck on “Queued…”** usually means the queue worker is not running (or not processing the queue you expect).
- Prefer using the Sail/Docker worker (see `docker-compose.yml`) rather than starting an additional local `php artisan queue:work`.
- Check worker status/logs: `./vendor/bin/sail ps` and `./vendor/bin/sail logs -f queue`.
- **Exit code 137** for `queue:work` typically means the process was killed (often OOM). Increase Docker memory/limits or run the worker inside the container.
### Configuration
- `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`): job refresh/progress chunk size.

View File

@ -0,0 +1,164 @@
<?php
namespace App\Console\Commands;
use App\Models\AuditLog;
use App\Models\BackupItem;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\RestoreRun;
use App\Models\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class TenantpilotPurgeNonPersistentData extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'tenantpilot:purge-nonpersistent
{tenant? : Tenant id / tenant_id / external_id (defaults to current tenant)}
{--all : Purge for all tenants}
{--force : Actually delete rows}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Permanently delete non-persistent (regeneratable) tenant data like policies, backups, runs, and logs.';
/**
* Execute the console command.
*/
public function handle(): int
{
$tenants = $this->resolveTenants();
if ($tenants->isEmpty()) {
$this->error('No tenants selected. Provide {tenant} or use --all.');
return self::FAILURE;
}
$isDryRun = ! (bool) $this->option('force');
if ($isDryRun) {
$this->warn('Dry run: no rows will be deleted. Re-run with --force to apply.');
} else {
$this->warn('This will PERMANENTLY delete non-persistent tenant data.');
if ($this->input->isInteractive() && ! $this->confirm('Proceed?', false)) {
$this->info('Aborted.');
return self::SUCCESS;
}
}
foreach ($tenants as $tenant) {
$counts = $this->countsForTenant($tenant);
$this->line('');
$this->info("Tenant: {$tenant->id} ({$tenant->name})");
$this->table(
['Table', 'Rows'],
collect($counts)
->map(fn (int $count, string $table) => [$table, $count])
->values()
->all(),
);
if ($isDryRun) {
continue;
}
DB::transaction(function () use ($tenant): void {
BackupScheduleRun::query()
->where('tenant_id', $tenant->id)
->delete();
BackupSchedule::query()
->where('tenant_id', $tenant->id)
->delete();
BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->delete();
AuditLog::query()
->where('tenant_id', $tenant->id)
->delete();
RestoreRun::withTrashed()
->where('tenant_id', $tenant->id)
->forceDelete();
BackupItem::withTrashed()
->where('tenant_id', $tenant->id)
->forceDelete();
BackupSet::withTrashed()
->where('tenant_id', $tenant->id)
->forceDelete();
PolicyVersion::withTrashed()
->where('tenant_id', $tenant->id)
->forceDelete();
Policy::query()
->where('tenant_id', $tenant->id)
->delete();
});
$this->info('Purged.');
}
return self::SUCCESS;
}
private function resolveTenants()
{
if ((bool) $this->option('all')) {
return Tenant::query()->get();
}
$tenantArg = $this->argument('tenant');
if ($tenantArg !== null && $tenantArg !== '') {
$tenant = Tenant::query()->forTenant($tenantArg)->first();
return $tenant ? collect([$tenant]) : collect();
}
try {
return collect([Tenant::current()]);
} catch (RuntimeException) {
return collect();
}
}
/**
* @return array<string,int>
*/
private function countsForTenant(Tenant $tenant): array
{
return [
'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(),
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
'bulk_operation_runs' => BulkOperationRun::query()->where('tenant_id', $tenant->id)->count(),
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(),
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),
'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(),
'policy_versions' => PolicyVersion::withTrashed()->where('tenant_id', $tenant->id)->count(),
'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(),
];
}
}

View File

@ -10,10 +10,10 @@
use App\Models\BackupScheduleRun;
use App\Models\Tenant;
use App\Models\User;
use App\Notifications\BackupScheduleRunDispatchedNotification;
use App\Rules\SupportedPolicyTypesRule;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Support\TenantRole;
use BackedEnum;
@ -30,6 +30,7 @@
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Events\DatabaseNotificationsSent;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
@ -41,6 +42,7 @@
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@ -200,50 +202,8 @@ public static function table(Table $table): Table
TextColumn::make('policy_types')
->label('Policy types')
->wrap()
->getStateUsing(function (BackupSchedule $record): string {
$state = $record->policy_types;
if (is_string($state)) {
$decoded = json_decode($state, true);
if (is_array($decoded)) {
$state = $decoded;
}
}
if ($state instanceof \Illuminate\Contracts\Support\Arrayable) {
$state = $state->toArray();
}
if (! is_array($state)) {
return 'None';
}
$types = array_is_list($state)
? $state
: array_keys(array_filter($state));
$types = array_values(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== ''));
if ($types === []) {
return 'None';
}
$labelMap = collect(config('tenantpilot.supported_policy_types', []))
->mapWithKeys(fn (array $policy): array => [
(string) ($policy['type'] ?? '') => (string) ($policy['label'] ?? Str::headline((string) ($policy['type'] ?? ''))),
])
->filter(fn (string $label, string $type): bool => $type !== '')
->all();
$labels = array_map(
fn (string $type): string => $labelMap[$type] ?? Str::headline($type),
$types,
);
return implode(', ', $labels);
}),
->getStateUsing(fn (BackupSchedule $record): string => static::policyTypesPreviewLabel($record))
->tooltip(fn (BackupSchedule $record): string => static::policyTypesFullLabel($record)),
TextColumn::make('retention_keep_last')
->label('Retention')
@ -278,7 +238,21 @@ public static function table(Table $table): Table
TextColumn::make('next_run_at')
->label('Next run')
->dateTime()
->getStateUsing(function (BackupSchedule $record): ?string {
$nextRun = $record->next_run_at;
if (! $nextRun) {
return null;
}
$timezone = $record->timezone ?: 'UTC';
try {
return $nextRun->setTimezone($timezone)->format('M j, Y H:i:s');
} catch (\Throwable) {
return $nextRun->format('M j, Y H:i:s');
}
})
->sortable(),
])
->filters([
@ -318,28 +292,37 @@ public static function table(Table $table): Table
$tenant = Tenant::current();
$user = auth()->user();
$userId = auth()->id();
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
$exists = BackupScheduleRun::query()
->where('backup_schedule_id', $record->id)
->where('scheduled_for', $scheduledFor)
->exists();
if (! $exists) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
$scheduledFor = $scheduledFor->addMinute();
}
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
if (! $run instanceof BackupScheduleRun) {
Notification::make()
->title('Run already queued')
->body('Please wait a moment and try again.')
->warning()
->send();
return;
}
app(AuditLogger::class)->log(
tenant: $tenant,
@ -357,23 +340,34 @@ public static function table(Table $table): Table
],
);
Bus::dispatch(new RunBackupScheduleJob($run->id));
$bulkRunId = null;
if ($user instanceof User) {
$user->notify(new BackupScheduleRunDispatchedNotification([
'tenant_id' => (int) $tenant->getKey(),
'backup_schedule_id' => (int) $record->id,
'backup_schedule_run_id' => (int) $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'run_now',
]));
if ($userModel instanceof User) {
$bulkRunId = app(BulkOperationService::class)
->createRun(
tenant: $tenant,
user: $userModel,
resource: 'backup_schedule',
action: 'run',
itemIds: [(string) $record->id],
totalItems: 1,
)
->id;
}
Notification::make()
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId));
$notification = Notification::make()
->title('Run dispatched')
->body('The backup run has been queued.')
->success()
->send();
->success();
if ($userModel instanceof User) {
$userModel->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($userModel);
}
$notification->send();
}),
Action::make('retry')
->label('Retry')
@ -385,28 +379,37 @@ public static function table(Table $table): Table
$tenant = Tenant::current();
$user = auth()->user();
$userId = auth()->id();
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
$exists = BackupScheduleRun::query()
->where('backup_schedule_id', $record->id)
->where('scheduled_for', $scheduledFor)
->exists();
if (! $exists) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
$scheduledFor = $scheduledFor->addMinute();
}
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
if (! $run instanceof BackupScheduleRun) {
Notification::make()
->title('Retry already queued')
->body('Please wait a moment and try again.')
->warning()
->send();
return;
}
app(AuditLogger::class)->log(
tenant: $tenant,
@ -424,23 +427,34 @@ public static function table(Table $table): Table
],
);
Bus::dispatch(new RunBackupScheduleJob($run->id));
$bulkRunId = null;
if ($user instanceof User) {
$user->notify(new BackupScheduleRunDispatchedNotification([
'tenant_id' => (int) $tenant->getKey(),
'backup_schedule_id' => (int) $record->id,
'backup_schedule_run_id' => (int) $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'retry',
]));
if ($userModel instanceof User) {
$bulkRunId = app(BulkOperationService::class)
->createRun(
tenant: $tenant,
user: $userModel,
resource: 'backup_schedule',
action: 'retry',
itemIds: [(string) $record->id],
totalItems: 1,
)
->id;
}
Notification::make()
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId));
$notification = Notification::make()
->title('Retry dispatched')
->body('A new backup run has been queued.')
->success()
->send();
->success();
if ($userModel instanceof User) {
$userModel->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($userModel);
}
$notification->send();
}),
EditAction::make()
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
@ -463,33 +477,47 @@ public static function table(Table $table): Table
}
$tenant = Tenant::current();
$user = auth()->user();
$userId = auth()->id();
$user = $userId ? User::query()->find($userId) : null;
$bulkRun = null;
if ($user) {
$bulkRun = app(\App\Services\BulkOperationService::class)->createRun(
tenant: $tenant,
user: $user,
resource: 'backup_schedule',
action: 'run',
itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(),
totalItems: $records->count(),
);
}
$createdRunIds = [];
/** @var BackupSchedule $record */
foreach ($records as $record) {
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
$exists = BackupScheduleRun::query()
->where('backup_schedule_id', $record->id)
->where('scheduled_for', $scheduledFor)
->exists();
if (! $exists) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
$scheduledFor = $scheduledFor->addMinute();
}
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
if (! $run instanceof BackupScheduleRun) {
continue;
}
$createdRunIds[] = (int) $run->id;
@ -505,28 +533,30 @@ public static function table(Table $table): Table
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'bulk_run_now',
'bulk_run_id' => $bulkRun?->id,
],
],
);
Bus::dispatch(new RunBackupScheduleJob($run->id));
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id));
}
$notification = Notification::make()
->title('Runs dispatched')
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
if (count($createdRunIds) === 0) {
$notification->warning();
} else {
$notification->success();
}
if ($user instanceof User) {
$user->notify(new BackupScheduleRunDispatchedNotification([
'tenant_id' => (int) $tenant->getKey(),
'schedule_ids' => $records->pluck('id')->map(fn ($id) => (int) $id)->values()->all(),
'backup_schedule_run_ids' => $createdRunIds,
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
'trigger' => 'bulk_run_now',
]));
$user->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($user);
}
Notification::make()
->title('Runs dispatched')
->body(sprintf('Queued %d run(s).', count($createdRunIds)))
->success()
->send();
$notification->send();
}),
BulkAction::make('bulk_retry')
->label('Retry')
@ -541,33 +571,47 @@ public static function table(Table $table): Table
}
$tenant = Tenant::current();
$user = auth()->user();
$userId = auth()->id();
$user = $userId ? User::query()->find($userId) : null;
$bulkRun = null;
if ($user) {
$bulkRun = app(\App\Services\BulkOperationService::class)->createRun(
tenant: $tenant,
user: $user,
resource: 'backup_schedule',
action: 'retry',
itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(),
totalItems: $records->count(),
);
}
$createdRunIds = [];
/** @var BackupSchedule $record */
foreach ($records as $record) {
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
$exists = BackupScheduleRun::query()
->where('backup_schedule_id', $record->id)
->where('scheduled_for', $scheduledFor)
->exists();
if (! $exists) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
$scheduledFor = $scheduledFor->addMinute();
}
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
if (! $run instanceof BackupScheduleRun) {
continue;
}
$createdRunIds[] = (int) $run->id;
@ -583,28 +627,30 @@ public static function table(Table $table): Table
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'bulk_retry',
'bulk_run_id' => $bulkRun?->id,
],
],
);
Bus::dispatch(new RunBackupScheduleJob($run->id));
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id));
}
$notification = Notification::make()
->title('Retries dispatched')
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
if (count($createdRunIds) === 0) {
$notification->warning();
} else {
$notification->success();
}
if ($user instanceof User) {
$user->notify(new BackupScheduleRunDispatchedNotification([
'tenant_id' => (int) $tenant->getKey(),
'schedule_ids' => $records->pluck('id')->map(fn ($id) => (int) $id)->values()->all(),
'backup_schedule_run_ids' => $createdRunIds,
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
'trigger' => 'bulk_retry',
]));
$user->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($user);
}
Notification::make()
->title('Retries dispatched')
->body(sprintf('Queued %d run(s).', count($createdRunIds)))
->success()
->send();
$notification->send();
}),
DeleteBulkAction::make('bulk_delete')
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
@ -638,6 +684,79 @@ public static function getPages(): array
];
}
public static function policyTypesFullLabel(BackupSchedule $record): string
{
$labels = static::policyTypesLabels($record);
return $labels === [] ? 'None' : implode(', ', $labels);
}
public static function policyTypesPreviewLabel(BackupSchedule $record): string
{
$labels = static::policyTypesLabels($record);
if ($labels === []) {
return 'None';
}
$preview = array_slice($labels, 0, 2);
$remaining = count($labels) - count($preview);
$label = implode(', ', $preview);
if ($remaining > 0) {
$label .= sprintf(' +%d more', $remaining);
}
return $label;
}
/**
* @return array<int, string>
*/
private static function policyTypesLabels(BackupSchedule $record): array
{
$state = $record->policy_types;
if (is_string($state)) {
$decoded = json_decode($state, true);
if (is_array($decoded)) {
$state = $decoded;
}
}
if ($state instanceof \Illuminate\Contracts\Support\Arrayable) {
$state = $state->toArray();
}
if (blank($state) || (! is_array($state))) {
return [];
}
$types = array_is_list($state)
? $state
: array_keys(array_filter($state));
$types = array_values(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== ''));
if ($types === []) {
return [];
}
$labelMap = collect(config('tenantpilot.supported_policy_types', []))
->mapWithKeys(fn (array $policy): array => [
(string) ($policy['type'] ?? '') => (string) ($policy['label'] ?? Str::headline((string) ($policy['type'] ?? ''))),
])
->filter(fn (string $label, string $type): bool => $type !== '')
->all();
return array_map(
fn (string $type): string => $labelMap[$type] ?? Str::headline($type),
$types,
);
}
public static function ensurePolicyTypes(array $data): array
{
$types = array_values((array) ($data['policy_types'] ?? []));

View File

@ -4,13 +4,17 @@
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BulkOperationRun;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\BackupScheduling\RunErrorMapper;
use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\BackupService;
use App\Services\Intune\PolicySyncService;
use Carbon\CarbonImmutable;
use Filament\Notifications\Events\DatabaseNotificationsSent;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -26,7 +30,10 @@ class RunBackupScheduleJob implements ShouldQueue
public int $tries = 3;
public function __construct(public int $backupScheduleRunId) {}
public function __construct(
public int $backupScheduleRunId,
public ?int $bulkRunId = null,
) {}
public function handle(
PolicySyncService $policySyncService,
@ -35,15 +42,31 @@ public function handle(
ScheduleTimeService $scheduleTimeService,
AuditLogger $auditLogger,
RunErrorMapper $errorMapper,
BulkOperationService $bulkOperationService,
): void {
$run = BackupScheduleRun::query()
->with(['schedule', 'tenant'])
->with(['schedule', 'tenant', 'user'])
->find($this->backupScheduleRunId);
if (! $run) {
return;
}
$bulkRun = $this->bulkRunId
? BulkOperationRun::query()->with(['tenant', 'user'])->find($this->bulkRunId)
: null;
if (
$bulkRun
&& ($bulkRun->tenant_id !== $run->tenant_id || $bulkRun->user_id !== $run->user_id)
) {
$bulkRun = null;
}
if ($bulkRun && $bulkRun->status === 'pending') {
$bulkOperationService->start($bulkRun);
}
$schedule = $run->schedule;
if (! $schedule instanceof BackupSchedule) {
@ -81,6 +104,7 @@ public function handle(
errorMessage: 'Another run is already in progress for this schedule.',
summary: ['reason' => 'concurrent_run'],
scheduleTimeService: $scheduleTimeService,
bulkRunId: $this->bulkRunId,
);
return;
@ -94,6 +118,8 @@ public function handle(
'status' => BackupScheduleRun::STATUS_RUNNING,
])->save();
$this->notifyRunStarted($run, $schedule);
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_started',
@ -124,6 +150,7 @@ public function handle(
'unknown_policy_types' => $unknownTypes,
],
scheduleTimeService: $scheduleTimeService,
bulkRunId: $this->bulkRunId,
);
return;
@ -182,6 +209,7 @@ public function handle(
summary: $summary,
scheduleTimeService: $scheduleTimeService,
backupSetId: (string) $backupSet->id,
bulkRunId: $this->bulkRunId,
);
$auditLogger->log(
@ -220,6 +248,7 @@ public function handle(
'attempt' => $attempt,
],
scheduleTimeService: $scheduleTimeService,
bulkRunId: $this->bulkRunId,
);
$auditLogger->log(
@ -241,6 +270,56 @@ public function handle(
}
}
private function notifyRunStarted(BackupScheduleRun $run, BackupSchedule $schedule): void
{
$user = $run->user;
if (! $user) {
return;
}
$notification = Notification::make()
->title('Backup started')
->body(sprintf('Schedule "%s" has started.', $schedule->name))
->info();
$user->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($user);
}
private function notifyRunFinished(BackupScheduleRun $run, BackupSchedule $schedule): void
{
$user = $run->user;
if (! $user) {
return;
}
$title = match ($run->status) {
BackupScheduleRun::STATUS_SUCCESS => 'Backup completed',
BackupScheduleRun::STATUS_PARTIAL => 'Backup completed (partial)',
BackupScheduleRun::STATUS_SKIPPED => 'Backup skipped',
default => 'Backup failed',
};
$notification = Notification::make()
->title($title)
->body(sprintf('Schedule "%s" finished with status: %s.', $schedule->name, $run->status));
if (filled($run->error_message)) {
$notification->body($notification->getBody()."\n".$run->error_message);
}
match ($run->status) {
BackupScheduleRun::STATUS_SUCCESS => $notification->success(),
BackupScheduleRun::STATUS_PARTIAL, BackupScheduleRun::STATUS_SKIPPED => $notification->warning(),
default => $notification->danger(),
};
$user->notifyNow($notification->toDatabase());
DatabaseNotificationsSent::dispatch($user);
}
private function finishRun(
BackupScheduleRun $run,
BackupSchedule $schedule,
@ -250,6 +329,7 @@ private function finishRun(
array $summary,
ScheduleTimeService $scheduleTimeService,
?string $backupSetId = null,
?int $bulkRunId = null,
): void {
$nowUtc = CarbonImmutable::now('UTC');
@ -268,6 +348,50 @@ private function finishRun(
'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc),
])->saveQuietly();
$this->notifyRunFinished($run, $schedule);
if ($bulkRunId) {
$bulkRun = BulkOperationRun::query()->with(['tenant', 'user'])->find($bulkRunId);
if (
$bulkRun
&& ($bulkRun->tenant_id === $run->tenant_id)
&& ($bulkRun->user_id === $run->user_id)
&& in_array($bulkRun->status, ['pending', 'running'], true)
) {
$service = app(BulkOperationService::class);
$itemId = (string) $run->backup_schedule_id;
match ($status) {
BackupScheduleRun::STATUS_SUCCESS => $service->recordSuccess($bulkRun),
BackupScheduleRun::STATUS_SKIPPED => $service->recordSkippedWithReason(
$bulkRun,
$itemId,
$errorMessage ?: 'Skipped',
),
BackupScheduleRun::STATUS_PARTIAL => $service->recordFailure(
$bulkRun,
$itemId,
$errorMessage ?: 'Completed partially',
),
default => $service->recordFailure(
$bulkRun,
$itemId,
$errorMessage ?: ($errorCode ?: 'Failed'),
),
};
$bulkRun->refresh();
if (
in_array($bulkRun->status, ['pending', 'running'], true)
&& $bulkRun->processed_items >= $bulkRun->total_items
) {
$service->complete($bulkRun);
}
}
}
if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) {
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
}

View File

@ -2,8 +2,10 @@
namespace App\Livewire;
use App\Models\BackupScheduleRun;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use Illuminate\Support\Arr;
use Livewire\Attributes\Computed;
use Livewire\Component;
@ -13,9 +15,12 @@ class BulkOperationProgress extends Component
public int $pollSeconds = 3;
public int $recentFinishedSeconds = 12;
public function mount()
{
$this->pollSeconds = max(1, min(10, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3)));
$this->recentFinishedSeconds = max(3, min(60, (int) config('tenantpilot.bulk_operations.recent_finished_seconds', 12)));
$this->loadRuns();
}
@ -35,12 +40,102 @@ public function loadRuns()
return;
}
$recentThreshold = now()->subSeconds($this->recentFinishedSeconds);
$this->runs = BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', auth()->id())
->whereIn('status', ['pending', 'running'])
->where(function ($query) use ($recentThreshold): void {
$query->whereIn('status', ['pending', 'running'])
->orWhere(function ($query) use ($recentThreshold): void {
$query->whereIn('status', ['completed', 'completed_with_errors', 'failed', 'aborted'])
->where('updated_at', '>=', $recentThreshold);
});
})
->orderByDesc('created_at')
->get();
$this->reconcileBackupScheduleRuns($tenant->id);
}
private function reconcileBackupScheduleRuns(int $tenantId): void
{
$userId = auth()->id();
if (! $userId) {
return;
}
$staleThreshold = now()->subSeconds(60);
foreach ($this->runs as $bulkRun) {
if ($bulkRun->resource !== 'backup_schedule') {
continue;
}
if (! in_array($bulkRun->status, ['pending', 'running'], true)) {
continue;
}
if (! $bulkRun->created_at || $bulkRun->created_at->gt($staleThreshold)) {
continue;
}
$scheduleId = (int) Arr::first($bulkRun->item_ids ?? []);
if ($scheduleId <= 0) {
continue;
}
$scheduleRun = BackupScheduleRun::query()
->where('tenant_id', $tenantId)
->where('user_id', $userId)
->where('backup_schedule_id', $scheduleId)
->where('created_at', '>=', $bulkRun->created_at)
->orderByDesc('id')
->first();
if (! $scheduleRun) {
continue;
}
if ($scheduleRun->finished_at) {
$processed = 1;
$succeeded = 0;
$failed = 0;
$skipped = 0;
$status = 'completed';
switch ($scheduleRun->status) {
case BackupScheduleRun::STATUS_SUCCESS:
$succeeded = 1;
break;
case BackupScheduleRun::STATUS_SKIPPED:
$skipped = 1;
break;
default:
$failed = 1;
$status = 'completed_with_errors';
break;
}
$bulkRun->forceFill([
'status' => $status,
'processed_items' => $processed,
'succeeded' => $succeeded,
'failed' => $failed,
'skipped' => $skipped,
])->save();
continue;
}
if ($scheduleRun->started_at && $bulkRun->status === 'pending') {
$bulkRun->forceFill(['status' => 'running'])->save();
}
}
}
public function render(): \Illuminate\Contracts\View\View

View File

@ -41,6 +41,11 @@ public function tenant(): BelongsTo
return $this->belongsTo(Tenant::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function backupSet(): BelongsTo
{
return $this->belongsTo(BackupSet::class);

View File

@ -8,8 +8,9 @@
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use Carbon\CarbonImmutable;
use Illuminate\Database\QueryException;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Log;
class BackupScheduleDispatcher
{
@ -71,10 +72,19 @@ public function dispatchDue(?array $tenantIdentifiers = null): array
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
} catch (QueryException $exception) {
} catch (UniqueConstraintViolationException) {
// Idempotency: unique (backup_schedule_id, scheduled_for)
$skippedRuns++;
Log::debug('Backup schedule run already dispatched for slot.', [
'schedule_id' => $schedule->id,
'slot' => $slot->toDateTimeString(),
]);
$schedule->forceFill([
'next_run_at' => $this->scheduleTimeService->nextRunFor($schedule, $nowUtc),
])->saveQuietly();
continue;
}

View File

@ -109,8 +109,24 @@ public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, s
public function complete(BulkOperationRun $run): void
{
$run->refresh();
if (! in_array($run->status, ['pending', 'running'], true)) {
return;
}
$status = $run->failed > 0 ? 'completed_with_errors' : 'completed';
$run->update(['status' => $status]);
$updated = BulkOperationRun::query()
->whereKey($run->id)
->whereIn('status', ['pending', 'running'])
->update(['status' => $status]);
if ($updated === 0) {
return;
}
$run->refresh();
$failureEntries = collect($run->failures ?? []);
$failedReasons = $failureEntries

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('backup_schedule_runs', function (Blueprint $table) {
$table->foreignId('user_id')
->nullable()
->after('tenant_id')
->constrained()
->nullOnDelete();
$table->index(['user_id', 'created_at'], 'backup_schedule_runs_user_created');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('backup_schedule_runs', function (Blueprint $table) {
$table->dropIndex('backup_schedule_runs_user_created');
$table->dropConstrainedForeignId('user_id');
});
}
};

View File

@ -13,12 +13,13 @@
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
@if($run->status === 'pending')
@php($isStalePending = $run->created_at->lt(now()->subSeconds(30)))
<span class="inline-flex items-center">
<svg class="animate-spin -ml-1 mr-1.5 h-3 w-3 text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Starting...
{{ $isStalePending ? 'Queued…' : 'Starting...' }}
</span>
@elseif($run->status === 'running')
<span class="inline-flex items-center">
@ -28,6 +29,10 @@
</svg>
Processing...
</span>
@elseif(in_array($run->status, ['completed', 'completed_with_errors'], true))
<span class="text-success-600 dark:text-success-400">Done</span>
@elseif(in_array($run->status, ['failed', 'aborted'], true))
<span class="text-danger-600 dark:text-danger-400">Failed</span>
@endif
</p>
</div>

View File

@ -88,6 +88,8 @@ ### Tests (Pest)
- [X] T029 [P] [US3] Add feature test tests/Feature/BackupScheduling/ApplyRetentionJobTest.php (keeps last N backup_sets; soft-deletes older)
- [X] T038 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (bulk delete action regression)
- [X] T039 [P] [US1] Extend tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (operator cannot bulk delete)
- [X] T041 [P] [US3] Make manual dispatch actions idempotent under concurrency in app/Filament/Resources/BackupScheduleResource.php (avoid unique constraint 500); add regression in tests/Feature/BackupScheduling/RunNowRetryActionsTest.php
- [X] T042 [P] [US2] Harden dispatcher idempotency in app/Services/BackupScheduling/BackupScheduleDispatcher.php (catch unique constraint only; treat as already dispatched, no side effects) and extend tests/Feature/BackupScheduling/DispatchIdempotencyTest.php
### Implementation

View File

@ -4,6 +4,7 @@
use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule;
use App\Models\BackupSchedule;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Livewire\Livewire;
@ -21,7 +22,11 @@
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'policy_types' => [
'deviceConfiguration',
'groupPolicyConfiguration',
'settingsCatalogPolicy',
],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
@ -45,9 +50,34 @@
->assertOk()
->assertSee('Tenant A schedule')
->assertSee('Device Configuration')
->assertSee('more')
->assertDontSee('Tenant B schedule');
});
test('backup schedules listing shows next run in schedule timezone', function () {
[$user, $tenant] = createUserWithTenant(role: 'manager');
BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Berlin schedule',
'is_enabled' => true,
'timezone' => 'Europe/Berlin',
'frequency' => 'daily',
'time_of_day' => '10:17:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
'next_run_at' => CarbonImmutable::create(2026, 1, 5, 9, 17, 0, 'UTC'),
]);
$this->actingAs($user);
$this->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($tenant)))
->assertOk()
->assertSee('Jan 5, 2026 10:17:00');
});
test('backup schedules pages return 404 for unauthorized tenant', function () {
[$user] = createUserWithTenant(role: 'manager');
$unauthorizedTenant = Tenant::factory()->create();

View File

@ -38,3 +38,44 @@
Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1);
});
it('treats a unique constraint collision as already-dispatched and advances next_run_at', function () {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Daily 10:00',
'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,
]);
BackupScheduleRun::query()->create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $tenant->id,
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
Bus::fake();
$dispatcher = app(BackupScheduleDispatcher::class);
$dispatcher->dispatchDue([$tenant->external_id]);
expect(BackupScheduleRun::query()->count())->toBe(1);
Bus::assertNotDispatched(RunBackupScheduleJob::class);
$schedule->refresh();
expect($schedule->next_run_at)->not->toBeNull();
expect($schedule->next_run_at->toDateTimeString())->toBe('2026-01-06 10:00:00');
});

View File

@ -4,7 +4,9 @@
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BulkOperationRun;
use App\Models\User;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
@ -38,6 +40,15 @@
$run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first();
expect($run)->not->toBeNull();
expect($run->user_id)->toBe($user->id);
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->where('resource', 'backup_schedule')
->where('action', 'run')
->count())
->toBe(1);
Queue::assertPushed(RunBackupScheduleJob::class);
@ -45,6 +56,8 @@
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->id,
'notifiable_type' => User::class,
'data->format' => 'filament',
'data->title' => 'Run dispatched',
]);
});
@ -75,8 +88,25 @@
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(1);
$run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first();
expect($run)->not->toBeNull();
expect($run->user_id)->toBe($user->id);
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->where('resource', 'backup_schedule')
->where('action', 'retry')
->count())
->toBe(1);
Queue::assertPushed(RunBackupScheduleJob::class);
$this->assertDatabaseCount('notifications', 1);
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->id,
'data->format' => 'filament',
'data->title' => 'Retry dispatched',
]);
});
test('readonly cannot dispatch run now or retry', function () {
@ -156,8 +186,24 @@
expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
->toBe(2);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id);
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->where('resource', 'backup_schedule')
->where('action', 'run')
->count())
->toBe(1);
Queue::assertPushed(RunBackupScheduleJob::class, 2);
$this->assertDatabaseCount('notifications', 1);
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->id,
'data->format' => 'filament',
'data->title' => 'Runs dispatched',
]);
});
test('operator can bulk retry and it persists a database notification', function () {
@ -200,6 +246,86 @@
expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
->toBe(2);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id);
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->where('resource', 'backup_schedule')
->where('action', 'retry')
->count())
->toBe(1);
Queue::assertPushed(RunBackupScheduleJob::class, 2);
$this->assertDatabaseCount('notifications', 1);
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->id,
'data->format' => 'filament',
'data->title' => 'Retries dispatched',
]);
});
test('operator can bulk retry even if a run already exists for this minute', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$scheduleA = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly A',
'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' => 30,
]);
$scheduleB = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly B',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '02:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
BackupScheduleRun::query()->create([
'backup_schedule_id' => $scheduleA->id,
'tenant_id' => $tenant->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListBackupSchedules::class)
->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB]));
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->count())
->toBe(2);
$newRunA = BackupScheduleRun::query()
->where('backup_schedule_id', $scheduleA->id)
->orderByDesc('id')
->first();
expect($newRunA)->not->toBeNull();
expect($newRunA->scheduled_for->setTimezone('UTC')->toDateTimeString())
->toBe($scheduledFor->addMinute()->toDateTimeString());
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->count())
->toBe(1);
Queue::assertPushed(RunBackupScheduleJob::class, 2);
});

View File

@ -1,6 +1,8 @@
<?php
use App\Livewire\BulkOperationProgress;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Models\User;
@ -30,6 +32,7 @@
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'status' => 'completed',
'updated_at' => now()->subMinutes(5),
]);
// Other user's op (should not show)
@ -47,3 +50,56 @@
->assertSee('Delete Policy')
->assertSee('50 / 100');
});
test('progress widget reconciles stale pending backup schedule runs', function () {
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$user = User::factory()->create();
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly',
'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' => 30,
'next_run_at' => now()->addHour(),
]);
$bulkRun = BulkOperationRun::factory()->create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'status' => 'pending',
'resource' => 'backup_schedule',
'action' => 'run',
'total_items' => 1,
'processed_items' => 0,
'item_ids' => [(string) $schedule->id],
'created_at' => now()->subMinutes(2),
'updated_at' => now()->subMinutes(2),
]);
BackupScheduleRun::query()->create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'scheduled_for' => now()->startOfMinute(),
'started_at' => now()->subMinute(),
'finished_at' => now(),
'status' => BackupScheduleRun::STATUS_SUCCESS,
'summary' => null,
]);
auth()->login($user);
Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->assertSee('Run Backup schedule')
->assertSee('1 / 1');
expect($bulkRun->refresh()->status)->toBe('completed');
});

View File

@ -0,0 +1,143 @@
<?php
use App\Models\AuditLog;
use App\Models\BackupItem;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\RestoreRun;
use App\Models\SettingsCatalogCategory;
use App\Models\SettingsCatalogDefinition;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('purges non-persistent tenant data without touching persistent catalog data', function () {
$tenantA = Tenant::factory()->create(['name' => 'Tenant A']);
$tenantB = Tenant::factory()->create(['name' => 'Tenant B']);
SettingsCatalogCategory::create([
'category_id' => 'cat-1',
'display_name' => 'Account Management',
'description' => null,
]);
SettingsCatalogDefinition::create([
'definition_id' => 'def-1',
'display_name' => 'Deletion Policy',
'description' => null,
'help_text' => null,
'category_id' => 'cat-1',
'ux_behavior' => null,
'raw' => [],
]);
$user = User::factory()->create();
$policyA = Policy::factory()->create(['tenant_id' => $tenantA->id]);
$policyB = Policy::factory()->create(['tenant_id' => $tenantB->id]);
PolicyVersion::factory()->create([
'tenant_id' => $tenantA->id,
'policy_id' => $policyA->id,
'version_number' => 1,
]);
PolicyVersion::factory()->create([
'tenant_id' => $tenantB->id,
'policy_id' => $policyB->id,
'version_number' => 1,
]);
$backupSetA = BackupSet::factory()->create(['tenant_id' => $tenantA->id]);
BackupItem::factory()->create([
'tenant_id' => $tenantA->id,
'backup_set_id' => $backupSetA->id,
'policy_id' => $policyA->id,
]);
RestoreRun::factory()->create([
'tenant_id' => $tenantA->id,
'backup_set_id' => $backupSetA->id,
]);
AuditLog::create([
'tenant_id' => $tenantA->id,
'actor_id' => null,
'actor_email' => null,
'actor_name' => null,
'action' => 'test.action',
'resource_type' => null,
'resource_id' => null,
'status' => 'success',
'metadata' => null,
'recorded_at' => now(),
]);
BulkOperationRun::factory()->create([
'tenant_id' => $tenantA->id,
'user_id' => $user->id,
'status' => 'completed',
]);
$scheduleA = BackupSchedule::create([
'tenant_id' => $tenantA->id,
'name' => 'Schedule A',
'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,
'last_run_at' => null,
'last_run_status' => null,
'next_run_at' => now()->addHour(),
]);
BackupScheduleRun::create([
'backup_schedule_id' => $scheduleA->id,
'tenant_id' => $tenantA->id,
'scheduled_for' => now()->startOfMinute(),
'started_at' => null,
'finished_at' => null,
'status' => BackupScheduleRun::STATUS_SUCCESS,
'summary' => null,
'error_code' => null,
'error_message' => null,
'backup_set_id' => $backupSetA->id,
]);
expect(Policy::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0);
expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0);
expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0);
$this->artisan('tenantpilot:purge-nonpersistent', [
'tenant' => $tenantA->id,
'--force' => true,
'--no-interaction' => true,
])->assertSuccessful();
expect(Policy::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(PolicyVersion::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(BackupItem::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(RestoreRun::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(AuditLog::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(BulkOperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(BackupSchedule::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
expect(Policy::query()->where('tenant_id', $tenantB->id)->count())->toBe(1);
expect(PolicyVersion::withTrashed()->where('tenant_id', $tenantB->id)->count())->toBe(1);
expect(SettingsCatalogCategory::query()->count())->toBe(1);
expect(SettingsCatalogDefinition::query()->count())->toBe(1);
});