diff --git a/README.md b/README.md index ddc34ef..edb6af5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/Console/Commands/TenantpilotPurgeNonPersistentData.php b/app/Console/Commands/TenantpilotPurgeNonPersistentData.php new file mode 100644 index 0000000..4b35693 --- /dev/null +++ b/app/Console/Commands/TenantpilotPurgeNonPersistentData.php @@ -0,0 +1,164 @@ +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 + */ + 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(), + ]; + } +} diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index 8ef28ce..a16c996 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -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 + */ + 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'] ?? [])); diff --git a/app/Jobs/RunBackupScheduleJob.php b/app/Jobs/RunBackupScheduleJob.php index 59a3524..23c802b 100644 --- a/app/Jobs/RunBackupScheduleJob.php +++ b/app/Jobs/RunBackupScheduleJob.php @@ -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)); } diff --git a/app/Livewire/BulkOperationProgress.php b/app/Livewire/BulkOperationProgress.php index 975a619..a7ef31d 100644 --- a/app/Livewire/BulkOperationProgress.php +++ b/app/Livewire/BulkOperationProgress.php @@ -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 diff --git a/app/Models/BackupScheduleRun.php b/app/Models/BackupScheduleRun.php index 76feb90..4091b20 100644 --- a/app/Models/BackupScheduleRun.php +++ b/app/Models/BackupScheduleRun.php @@ -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); diff --git a/app/Services/BackupScheduling/BackupScheduleDispatcher.php b/app/Services/BackupScheduling/BackupScheduleDispatcher.php index f0bd207..be63a73 100644 --- a/app/Services/BackupScheduling/BackupScheduleDispatcher.php +++ b/app/Services/BackupScheduling/BackupScheduleDispatcher.php @@ -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; } diff --git a/app/Services/BulkOperationService.php b/app/Services/BulkOperationService.php index 76b3af7..8f3f158 100644 --- a/app/Services/BulkOperationService.php +++ b/app/Services/BulkOperationService.php @@ -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 diff --git a/database/migrations/2026_01_06_211013_add_user_id_to_backup_schedule_runs_table.php b/database/migrations/2026_01_06_211013_add_user_id_to_backup_schedule_runs_table.php new file mode 100644 index 0000000..b01bc37 --- /dev/null +++ b/database/migrations/2026_01_06_211013_add_user_id_to_backup_schedule_runs_table.php @@ -0,0 +1,35 @@ +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'); + }); + } +}; diff --git a/resources/views/livewire/bulk-operation-progress.blade.php b/resources/views/livewire/bulk-operation-progress.blade.php index c254211..faf75fb 100644 --- a/resources/views/livewire/bulk-operation-progress.blade.php +++ b/resources/views/livewire/bulk-operation-progress.blade.php @@ -13,12 +13,13 @@

@if($run->status === 'pending') + @php($isStalePending = $run->created_at->lt(now()->subSeconds(30))) - Starting... + {{ $isStalePending ? 'Queued…' : 'Starting...' }} @elseif($run->status === 'running') @@ -28,6 +29,10 @@ Processing... + @elseif(in_array($run->status, ['completed', 'completed_with_errors'], true)) + Done + @elseif(in_array($run->status, ['failed', 'aborted'], true)) + Failed @endif

diff --git a/specs/032-backup-scheduling-mvp/tasks.md b/specs/032-backup-scheduling-mvp/tasks.md index 03cca66..ee1571c 100644 --- a/specs/032-backup-scheduling-mvp/tasks.md +++ b/specs/032-backup-scheduling-mvp/tasks.md @@ -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 diff --git a/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php b/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php index 90c9f73..f7c6726 100644 --- a/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php +++ b/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php @@ -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(); diff --git a/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php index 8ab996b..8df6cd6 100644 --- a/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php +++ b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php @@ -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'); +}); diff --git a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php index 276fcf4..807fc5b 100644 --- a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php +++ b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php @@ -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); }); diff --git a/tests/Feature/BulkProgressNotificationTest.php b/tests/Feature/BulkProgressNotificationTest.php index 19395e6..0f345ec 100644 --- a/tests/Feature/BulkProgressNotificationTest.php +++ b/tests/Feature/BulkProgressNotificationTest.php @@ -1,6 +1,8 @@ $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'); +}); diff --git a/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php b/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php new file mode 100644 index 0000000..40192e5 --- /dev/null +++ b/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php @@ -0,0 +1,143 @@ +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); +});