From 0b77c99975432aee0fd6297ba2371d10c089baf6 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 5 Jan 2026 05:15:47 +0100 Subject: [PATCH] feat(backup-scheduling): finish MVP + UX polish --- .gitignore | 7 +- .../TenantpilotDispatchBackupSchedules.php | 29 + app/Exceptions/InvalidPolicyTypeException.php | 17 + .../Resources/BackupScheduleResource.php | 737 ++++++++++++++++++ .../Pages/CreateBackupSchedule.php | 19 + .../Pages/EditBackupSchedule.php | 18 + .../Pages/ListBackupSchedules.php | 19 + .../BackupScheduleRunsRelationManager.php | 110 +++ app/Jobs/ApplyBackupScheduleRetentionJob.php | 100 +++ app/Jobs/RunBackupScheduleJob.php | 275 +++++++ app/Models/BackupSchedule.php | 34 + app/Models/BackupScheduleRun.php | 48 ++ app/Models/Tenant.php | 10 + ...ackupScheduleRunDispatchedNotification.php | 55 ++ app/Policies/BackupSchedulePolicy.php | 46 ++ app/Providers/AppServiceProvider.php | 5 + app/Rules/SupportedPolicyTypesRule.php | 22 + .../BackupScheduleDispatcher.php | 133 ++++ .../BackupScheduling/PolicyTypeResolver.php | 55 ++ .../BackupScheduling/RunErrorMapper.php | 86 ++ .../BackupScheduling/ScheduleTimeService.php | 88 +++ app/Support/TenantRole.php | 19 + ...5_011014_create_backup_schedules_table.php | 43 + ...1034_create_backup_schedule_runs_table.php | 41 + .../modals/backup-schedule-run-view.blade.php | 48 ++ routes/console.php | 3 + .../checklists/requirements.md | 22 +- specs/032-backup-scheduling-mvp/plan.md | 2 +- specs/032-backup-scheduling-mvp/quickstart.md | 9 + specs/032-backup-scheduling-mvp/spec.md | 9 +- specs/032-backup-scheduling-mvp/tasks.md | 137 +++- .../ApplyRetentionJobTest.php | 67 ++ .../BackupScheduleBulkDeleteTest.php | 89 +++ .../BackupScheduleCrudTest.php | 93 +++ .../BackupScheduleRunViewModalTest.php | 53 ++ .../BackupScheduleValidationTest.php | 48 ++ .../DispatchIdempotencyTest.php | 40 + .../RunBackupScheduleJobTest.php | 123 +++ .../BackupScheduling/RunErrorMappingTest.php | 42 + .../RunNowRetryActionsTest.php | 205 +++++ .../ScheduleTimeServiceTest.php | 45 ++ 41 files changed, 3001 insertions(+), 50 deletions(-) create mode 100644 app/Console/Commands/TenantpilotDispatchBackupSchedules.php create mode 100644 app/Exceptions/InvalidPolicyTypeException.php create mode 100644 app/Filament/Resources/BackupScheduleResource.php create mode 100644 app/Filament/Resources/BackupScheduleResource/Pages/CreateBackupSchedule.php create mode 100644 app/Filament/Resources/BackupScheduleResource/Pages/EditBackupSchedule.php create mode 100644 app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php create mode 100644 app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php create mode 100644 app/Jobs/ApplyBackupScheduleRetentionJob.php create mode 100644 app/Jobs/RunBackupScheduleJob.php create mode 100644 app/Models/BackupSchedule.php create mode 100644 app/Models/BackupScheduleRun.php create mode 100644 app/Notifications/BackupScheduleRunDispatchedNotification.php create mode 100644 app/Policies/BackupSchedulePolicy.php create mode 100644 app/Rules/SupportedPolicyTypesRule.php create mode 100644 app/Services/BackupScheduling/BackupScheduleDispatcher.php create mode 100644 app/Services/BackupScheduling/PolicyTypeResolver.php create mode 100644 app/Services/BackupScheduling/RunErrorMapper.php create mode 100644 app/Services/BackupScheduling/ScheduleTimeService.php create mode 100644 database/migrations/2026_01_05_011014_create_backup_schedules_table.php create mode 100644 database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php create mode 100644 resources/views/filament/modals/backup-schedule-run-view.blade.php create mode 100644 tests/Feature/BackupScheduling/ApplyRetentionJobTest.php create mode 100644 tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php create mode 100644 tests/Feature/BackupScheduling/BackupScheduleCrudTest.php create mode 100644 tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php create mode 100644 tests/Feature/BackupScheduling/BackupScheduleValidationTest.php create mode 100644 tests/Feature/BackupScheduling/DispatchIdempotencyTest.php create mode 100644 tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php create mode 100644 tests/Feature/BackupScheduling/RunErrorMappingTest.php create mode 100644 tests/Feature/BackupScheduling/RunNowRetryActionsTest.php create mode 100644 tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php diff --git a/.gitignore b/.gitignore index 1b59610..766ffe9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ /.zed /auth.json /node_modules +dist/ +build/ +coverage/ /public/build /public/hot /public/storage @@ -22,4 +25,6 @@ Homestead.json Homestead.yaml Thumbs.db -/references \ No newline at end of file +/references +*.tmp +*.swp diff --git a/app/Console/Commands/TenantpilotDispatchBackupSchedules.php b/app/Console/Commands/TenantpilotDispatchBackupSchedules.php new file mode 100644 index 0000000..6c2e12b --- /dev/null +++ b/app/Console/Commands/TenantpilotDispatchBackupSchedules.php @@ -0,0 +1,29 @@ +option('tenant'); + + $result = $dispatcher->dispatchDue($tenantIdentifiers); + + $this->info(sprintf( + 'Scanned %d schedule(s), created %d run(s), skipped %d duplicate run(s).', + $result['scanned_schedules'], + $result['created_runs'], + $result['skipped_runs'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Exceptions/InvalidPolicyTypeException.php b/app/Exceptions/InvalidPolicyTypeException.php new file mode 100644 index 0000000..0c751e4 --- /dev/null +++ b/app/Exceptions/InvalidPolicyTypeException.php @@ -0,0 +1,17 @@ +unknownPolicyTypes = array_values($unknownPolicyTypes); + + parent::__construct('Unknown policy types: '.implode(', ', $this->unknownPolicyTypes)); + } +} diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php new file mode 100644 index 0000000..8ef28ce --- /dev/null +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -0,0 +1,737 @@ +user(); + + if (! $user instanceof User) { + return null; + } + + return $user->tenantRole(Tenant::current()); + } + + public static function canViewAny(): bool + { + return static::currentTenantRole() !== null; + } + + public static function canView(Model $record): bool + { + return static::currentTenantRole() !== null; + } + + public static function canCreate(): bool + { + return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + } + + public static function canEdit(Model $record): bool + { + return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + } + + public static function canDelete(Model $record): bool + { + return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + } + + public static function canDeleteAny(): bool + { + return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + } + + public static function form(Schema $schema): Schema + { + return $schema + ->schema([ + TextInput::make('name') + ->label('Schedule Name') + ->required() + ->maxLength(255), + + Toggle::make('is_enabled') + ->label('Enabled') + ->default(true), + + Select::make('timezone') + ->label('Timezone') + ->options(static::timezoneOptions()) + ->searchable() + ->default('UTC') + ->required(), + + Select::make('frequency') + ->label('Frequency') + ->options([ + 'daily' => 'Daily', + 'weekly' => 'Weekly', + ]) + ->default('daily') + ->required() + ->reactive(), + + TextInput::make('time_of_day') + ->label('Time of day') + ->type('time') + ->required() + ->extraInputAttributes(['step' => 60]), + + CheckboxList::make('days_of_week') + ->label('Days of the week') + ->options(static::dayOfWeekOptions()) + ->columns(2) + ->visible(fn (Get $get): bool => $get('frequency') === 'weekly') + ->required(fn (Get $get): bool => $get('frequency') === 'weekly') + ->rules(['array', 'min:1']), + + CheckboxList::make('policy_types') + ->label('Policy types') + ->options(static::policyTypeOptions()) + ->columns(2) + ->required() + ->helperText('Select the Microsoft Graph policy types that should be included in each run.') + ->rules([ + 'array', + 'min:1', + new SupportedPolicyTypesRule, + ]) + ->columnSpanFull(), + + Toggle::make('include_foundations') + ->label('Include foundation types') + ->default(true), + + TextInput::make('retention_keep_last') + ->label('Retention (keep last N Backup Sets)') + ->type('number') + ->default(30) + ->minValue(1) + ->required(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('next_run_at', 'asc') + ->columns([ + IconColumn::make('is_enabled') + ->label('Enabled') + ->boolean() + ->alignCenter(), + + TextColumn::make('name') + ->searchable() + ->label('Schedule'), + + TextColumn::make('frequency') + ->label('Frequency') + ->badge() + ->formatStateUsing(fn (?string $state): string => match ($state) { + 'daily' => 'Daily', + 'weekly' => 'Weekly', + default => (string) $state, + }) + ->color(fn (?string $state): string => match ($state) { + 'daily' => 'success', + 'weekly' => 'warning', + default => 'gray', + }), + + TextColumn::make('time_of_day') + ->label('Time') + ->formatStateUsing(fn (?string $state): ?string => $state ? trim($state) : null), + + TextColumn::make('timezone') + ->label('Timezone'), + + 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); + }), + + TextColumn::make('retention_keep_last') + ->label('Retention') + ->suffix(' sets'), + + TextColumn::make('last_run_status') + ->label('Last run status') + ->badge() + ->formatStateUsing(fn (?string $state): string => match ($state) { + BackupScheduleRun::STATUS_RUNNING => 'Running', + BackupScheduleRun::STATUS_SUCCESS => 'Success', + BackupScheduleRun::STATUS_PARTIAL => 'Partial', + BackupScheduleRun::STATUS_FAILED => 'Failed', + BackupScheduleRun::STATUS_CANCELED => 'Canceled', + BackupScheduleRun::STATUS_SKIPPED => 'Skipped', + default => $state ? Str::headline($state) : '—', + }) + ->color(fn (?string $state): string => match ($state) { + BackupScheduleRun::STATUS_SUCCESS => 'success', + BackupScheduleRun::STATUS_PARTIAL => 'warning', + BackupScheduleRun::STATUS_RUNNING => 'primary', + BackupScheduleRun::STATUS_SKIPPED => 'gray', + BackupScheduleRun::STATUS_FAILED, + BackupScheduleRun::STATUS_CANCELED => 'danger', + default => 'gray', + }), + + TextColumn::make('last_run_at') + ->label('Last run') + ->dateTime() + ->sortable(), + + TextColumn::make('next_run_at') + ->label('Next run') + ->dateTime() + ->sortable(), + ]) + ->filters([ + SelectFilter::make('enabled_state') + ->label('Enabled') + ->options([ + 'enabled' => 'Enabled', + 'disabled' => 'Disabled', + ]) + ->query(function (Builder $query, array $data): void { + $value = $data['value'] ?? null; + + if (blank($value)) { + return; + } + + if ($value === 'enabled') { + $query->where('is_enabled', true); + + return; + } + + if ($value === 'disabled') { + $query->where('is_enabled', false); + } + }), + ]) + ->actions([ + ActionGroup::make([ + Action::make('runNow') + ->label('Run now') + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) + ->action(function (BackupSchedule $record): void { + abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); + + $tenant = Tenant::current(); + $user = auth()->user(); + + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + for ($i = 0; $i < 5; $i++) { + $exists = BackupScheduleRun::query() + ->where('backup_schedule_id', $record->id) + ->where('scheduled_for', $scheduledFor) + ->exists(); + + if (! $exists) { + break; + } + + $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, + ]); + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'run_now', + ], + ], + ); + + Bus::dispatch(new RunBackupScheduleJob($run->id)); + + 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', + ])); + } + + Notification::make() + ->title('Run dispatched') + ->body('The backup run has been queued.') + ->success() + ->send(); + }), + Action::make('retry') + ->label('Retry') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) + ->action(function (BackupSchedule $record): void { + abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); + + $tenant = Tenant::current(); + $user = auth()->user(); + + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + for ($i = 0; $i < 5; $i++) { + $exists = BackupScheduleRun::query() + ->where('backup_schedule_id', $record->id) + ->where('scheduled_for', $scheduledFor) + ->exists(); + + if (! $exists) { + break; + } + + $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, + ]); + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'retry', + ], + ], + ); + + Bus::dispatch(new RunBackupScheduleJob($run->id)); + + 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', + ])); + } + + Notification::make() + ->title('Retry dispatched') + ->body('A new backup run has been queued.') + ->success() + ->send(); + }), + EditAction::make() + ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), + DeleteAction::make() + ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), + ])->icon('heroicon-o-ellipsis-vertical'), + ]) + ->bulkActions([ + BulkActionGroup::make([ + BulkAction::make('bulk_run_now') + ->label('Run now') + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) + ->action(function (Collection $records): void { + abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); + + if ($records->isEmpty()) { + return; + } + + $tenant = Tenant::current(); + $user = auth()->user(); + + $createdRunIds = []; + + /** @var BackupSchedule $record */ + foreach ($records as $record) { + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + for ($i = 0; $i < 5; $i++) { + $exists = BackupScheduleRun::query() + ->where('backup_schedule_id', $record->id) + ->where('scheduled_for', $scheduledFor) + ->exists(); + + if (! $exists) { + break; + } + + $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, + ]); + + $createdRunIds[] = (int) $run->id; + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'bulk_run_now', + ], + ], + ); + + Bus::dispatch(new RunBackupScheduleJob($run->id)); + } + + 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', + ])); + } + + Notification::make() + ->title('Runs dispatched') + ->body(sprintf('Queued %d run(s).', count($createdRunIds))) + ->success() + ->send(); + }), + BulkAction::make('bulk_retry') + ->label('Retry') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) + ->action(function (Collection $records): void { + abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); + + if ($records->isEmpty()) { + return; + } + + $tenant = Tenant::current(); + $user = auth()->user(); + + $createdRunIds = []; + + /** @var BackupSchedule $record */ + foreach ($records as $record) { + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + for ($i = 0; $i < 5; $i++) { + $exists = BackupScheduleRun::query() + ->where('backup_schedule_id', $record->id) + ->where('scheduled_for', $scheduledFor) + ->exists(); + + if (! $exists) { + break; + } + + $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, + ]); + + $createdRunIds[] = (int) $run->id; + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'bulk_retry', + ], + ], + ); + + Bus::dispatch(new RunBackupScheduleJob($run->id)); + } + + 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', + ])); + } + + Notification::make() + ->title('Retries dispatched') + ->body(sprintf('Queued %d run(s).', count($createdRunIds))) + ->success() + ->send(); + }), + DeleteBulkAction::make('bulk_delete') + ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), + ]), + ]); + } + + public static function getEloquentQuery(): Builder + { + $tenantId = Tenant::current()->getKey(); + + return parent::getEloquentQuery() + ->where('tenant_id', $tenantId) + ->orderByDesc('is_enabled') + ->orderBy('next_run_at'); + } + + public static function getRelations(): array + { + return [ + BackupScheduleRunsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListBackupSchedules::route('/'), + 'create' => Pages\CreateBackupSchedule::route('/create'), + 'edit' => Pages\EditBackupSchedule::route('/{record}/edit'), + ]; + } + + public static function ensurePolicyTypes(array $data): array + { + $types = array_values((array) ($data['policy_types'] ?? [])); + + try { + app(PolicyTypeResolver::class)->ensureSupported($types); + } catch (InvalidPolicyTypeException $exception) { + throw ValidationException::withMessages([ + 'policy_types' => [sprintf('Unknown policy types: %s.', implode(', ', $exception->unknownPolicyTypes))], + ]); + } + + $data['policy_types'] = $types; + + return $data; + } + + public static function assignTenant(array $data): array + { + $data['tenant_id'] = Tenant::current()->getKey(); + + return $data; + } + + public static function hydrateNextRun(array $data): array + { + if (! empty($data['time_of_day'])) { + $data['time_of_day'] = static::normalizeTimeOfDay($data['time_of_day']); + } + + $schedule = new BackupSchedule; + $schedule->forceFill([ + 'frequency' => $data['frequency'] ?? 'daily', + 'time_of_day' => $data['time_of_day'] ?? '00:00:00', + 'timezone' => $data['timezone'] ?? 'UTC', + 'days_of_week' => (array) ($data['days_of_week'] ?? []), + ]); + + $nextRun = app(ScheduleTimeService::class)->nextRunFor($schedule); + + $data['next_run_at'] = $nextRun?->toDateTimeString(); + + return $data; + } + + public static function normalizeTimeOfDay(string $time): string + { + if (preg_match('/^\d{2}:\d{2}$/', $time)) { + return $time.':00'; + } + + return $time; + } + + protected static function timezoneOptions(): array + { + $zones = DateTimeZone::listIdentifiers(); + + sort($zones); + + return array_combine($zones, $zones); + } + + protected static function policyTypeOptions(): array + { + return static::policyTypeLabelMap(); + } + + protected static function policyTypeLabels(array $types): array + { + $map = static::policyTypeLabelMap(); + + return array_map(fn (string $type): string => $map[$type] ?? Str::headline($type), $types); + } + + protected static function policyTypeLabelMap(): array + { + return collect(config('tenantpilot.supported_policy_types', [])) + ->mapWithKeys(fn (array $policy) => [ + $policy['type'] => $policy['label'] ?? Str::headline($policy['type']), + ]) + ->all(); + } + + protected static function dayOfWeekOptions(): array + { + return [ + 1 => 'Monday', + 2 => 'Tuesday', + 3 => 'Wednesday', + 4 => 'Thursday', + 5 => 'Friday', + 6 => 'Saturday', + 7 => 'Sunday', + ]; + } +} diff --git a/app/Filament/Resources/BackupScheduleResource/Pages/CreateBackupSchedule.php b/app/Filament/Resources/BackupScheduleResource/Pages/CreateBackupSchedule.php new file mode 100644 index 0000000..d398e46 --- /dev/null +++ b/app/Filament/Resources/BackupScheduleResource/Pages/CreateBackupSchedule.php @@ -0,0 +1,19 @@ +modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet')) + ->defaultSort('scheduled_for', 'desc') + ->columns([ + Tables\Columns\TextColumn::make('scheduled_for') + ->label('Scheduled for') + ->dateTime(), + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn (?string $state): string => match ($state) { + BackupScheduleRun::STATUS_SUCCESS => 'success', + BackupScheduleRun::STATUS_PARTIAL => 'warning', + BackupScheduleRun::STATUS_RUNNING => 'primary', + BackupScheduleRun::STATUS_SKIPPED => 'gray', + BackupScheduleRun::STATUS_FAILED, + BackupScheduleRun::STATUS_CANCELED => 'danger', + default => 'gray', + }), + Tables\Columns\TextColumn::make('duration') + ->label('Duration') + ->getStateUsing(function (BackupScheduleRun $record): string { + if (! $record->started_at || ! $record->finished_at) { + return '—'; + } + + $seconds = max(0, $record->started_at->diffInSeconds($record->finished_at)); + + if ($seconds < 60) { + return $seconds.'s'; + } + + $minutes = intdiv($seconds, 60); + $rem = $seconds % 60; + + return sprintf('%dm %ds', $minutes, $rem); + }), + Tables\Columns\TextColumn::make('counts') + ->label('Counts') + ->getStateUsing(function (BackupScheduleRun $record): string { + $summary = is_array($record->summary) ? $record->summary : []; + + $total = (int) ($summary['policies_total'] ?? 0); + $backedUp = (int) ($summary['policies_backed_up'] ?? 0); + $errors = (int) ($summary['errors_count'] ?? 0); + + if ($total === 0 && $backedUp === 0 && $errors === 0) { + return '—'; + } + + return sprintf('%d/%d (%d errors)', $backedUp, $total, $errors); + }), + Tables\Columns\TextColumn::make('error_code') + ->label('Error') + ->badge() + ->default('—'), + Tables\Columns\TextColumn::make('error_message') + ->label('Message') + ->default('—') + ->limit(80) + ->wrap(), + Tables\Columns\TextColumn::make('backup_set_id') + ->label('Backup set') + ->default('—') + ->url(function (BackupScheduleRun $record): ?string { + if (! $record->backup_set_id) { + return null; + } + + return BackupSetResource::getUrl('view', ['record' => $record->backup_set_id], tenant: Tenant::current()); + }) + ->openUrlInNewTab(true), + ]) + ->filters([]) + ->headerActions([]) + ->actions([ + Actions\Action::make('view') + ->label('View') + ->icon('heroicon-o-eye') + ->modalHeading('View backup schedule run') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(function (BackupScheduleRun $record): View { + return view('filament.modals.backup-schedule-run-view', [ + 'run' => $record, + ]); + }), + ]) + ->bulkActions([]); + } +} diff --git a/app/Jobs/ApplyBackupScheduleRetentionJob.php b/app/Jobs/ApplyBackupScheduleRetentionJob.php new file mode 100644 index 0000000..09f98bf --- /dev/null +++ b/app/Jobs/ApplyBackupScheduleRetentionJob.php @@ -0,0 +1,100 @@ +with('tenant') + ->find($this->backupScheduleId); + + if (! $schedule || ! $schedule->tenant) { + return; + } + + $keepLast = (int) ($schedule->retention_keep_last ?? 30); + + if ($keepLast < 1) { + $keepLast = 1; + } + + /** @var Collection $keepBackupSetIds */ + $keepBackupSetIds = BackupScheduleRun::query() + ->where('backup_schedule_id', $schedule->id) + ->whereNotNull('backup_set_id') + ->orderByDesc('scheduled_for') + ->limit($keepLast) + ->pluck('backup_set_id') + ->filter() + ->values(); + + /** @var Collection $deleteBackupSetIds */ + $deleteBackupSetIds = BackupScheduleRun::query() + ->where('backup_schedule_id', $schedule->id) + ->whereNotNull('backup_set_id') + ->when($keepBackupSetIds->isNotEmpty(), fn ($query) => $query->whereNotIn('backup_set_id', $keepBackupSetIds->all())) + ->pluck('backup_set_id') + ->filter() + ->unique() + ->values(); + + if ($deleteBackupSetIds->isEmpty()) { + $auditLogger->log( + tenant: $schedule->tenant, + action: 'backup_schedule.retention_applied', + resourceType: 'backup_schedule', + resourceId: (string) $schedule->id, + status: 'success', + context: [ + 'metadata' => [ + 'keep_last' => $keepLast, + 'deleted_backup_sets' => 0, + ], + ], + ); + + return; + } + + $deletedCount = 0; + + BackupSet::query() + ->where('tenant_id', $schedule->tenant_id) + ->whereIn('id', $deleteBackupSetIds->all()) + ->whereNull('deleted_at') + ->chunkById(200, function (Collection $sets) use (&$deletedCount): void { + foreach ($sets as $set) { + $set->delete(); + $deletedCount++; + } + }); + + $auditLogger->log( + tenant: $schedule->tenant, + action: 'backup_schedule.retention_applied', + resourceType: 'backup_schedule', + resourceId: (string) $schedule->id, + status: 'success', + context: [ + 'metadata' => [ + 'keep_last' => $keepLast, + 'deleted_backup_sets' => $deletedCount, + ], + ], + ); + } +} diff --git a/app/Jobs/RunBackupScheduleJob.php b/app/Jobs/RunBackupScheduleJob.php new file mode 100644 index 0000000..59a3524 --- /dev/null +++ b/app/Jobs/RunBackupScheduleJob.php @@ -0,0 +1,275 @@ +with(['schedule', 'tenant']) + ->find($this->backupScheduleRunId); + + if (! $run) { + return; + } + + $schedule = $run->schedule; + + if (! $schedule instanceof BackupSchedule) { + $run->update([ + 'status' => BackupScheduleRun::STATUS_FAILED, + 'error_code' => RunErrorMapper::ERROR_UNKNOWN, + 'error_message' => 'Schedule not found.', + 'finished_at' => CarbonImmutable::now('UTC'), + ]); + + return; + } + + $tenant = $run->tenant; + + if (! $tenant) { + $run->update([ + 'status' => BackupScheduleRun::STATUS_FAILED, + 'error_code' => RunErrorMapper::ERROR_UNKNOWN, + 'error_message' => 'Tenant not found.', + 'finished_at' => CarbonImmutable::now('UTC'), + ]); + + return; + } + + $lock = Cache::lock("backup_schedule:{$schedule->id}", 900); + + if (! $lock->get()) { + $this->finishRun( + run: $run, + schedule: $schedule, + status: BackupScheduleRun::STATUS_SKIPPED, + errorCode: 'CONCURRENT_RUN', + errorMessage: 'Another run is already in progress for this schedule.', + summary: ['reason' => 'concurrent_run'], + scheduleTimeService: $scheduleTimeService, + ); + + return; + } + + try { + $nowUtc = CarbonImmutable::now('UTC'); + + $run->forceFill([ + 'started_at' => $run->started_at ?? $nowUtc, + 'status' => BackupScheduleRun::STATUS_RUNNING, + ])->save(); + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_started', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $run->scheduled_for?->toDateTimeString(), + ], + ], + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success' + ); + + $runtime = $policyTypeResolver->resolveRuntime((array) ($schedule->policy_types ?? [])); + $validTypes = $runtime['valid']; + $unknownTypes = $runtime['unknown']; + + if (empty($validTypes)) { + $this->finishRun( + run: $run, + schedule: $schedule, + status: BackupScheduleRun::STATUS_SKIPPED, + errorCode: 'UNKNOWN_POLICY_TYPE', + errorMessage: 'All configured policy types are unknown.', + summary: [ + 'unknown_policy_types' => $unknownTypes, + ], + scheduleTimeService: $scheduleTimeService, + ); + + return; + } + + $supported = array_values(array_filter( + config('tenantpilot.supported_policy_types', []), + fn (array $typeConfig): bool => in_array($typeConfig['type'] ?? null, $validTypes, true), + )); + + $syncReport = $policySyncService->syncPoliciesWithReport($tenant, $supported); + + $policyIds = $syncReport['synced'] ?? []; + $syncFailures = $syncReport['failures'] ?? []; + + $backupSet = $backupService->createBackupSet( + tenant: $tenant, + policyIds: $policyIds, + actorEmail: null, + actorName: null, + name: 'Scheduled backup: '.$schedule->name, + includeAssignments: false, + includeScopeTags: false, + includeFoundations: (bool) ($schedule->include_foundations ?? false), + ); + + $status = match ($backupSet->status) { + 'completed' => BackupScheduleRun::STATUS_SUCCESS, + 'partial' => BackupScheduleRun::STATUS_PARTIAL, + 'failed' => BackupScheduleRun::STATUS_FAILED, + default => BackupScheduleRun::STATUS_SUCCESS, + }; + + $errorCode = null; + $errorMessage = null; + + $summary = [ + 'policies_total' => count($policyIds), + 'policies_backed_up' => (int) ($backupSet->item_count ?? 0), + 'sync_failures' => $syncFailures, + ]; + + if (! empty($unknownTypes)) { + $status = BackupScheduleRun::STATUS_PARTIAL; + $errorCode = 'UNKNOWN_POLICY_TYPE'; + $errorMessage = 'Some configured policy types are unknown and were skipped.'; + $summary['unknown_policy_types'] = $unknownTypes; + } + + $this->finishRun( + run: $run, + schedule: $schedule, + status: $status, + errorCode: $errorCode, + errorMessage: $errorMessage, + summary: $summary, + scheduleTimeService: $scheduleTimeService, + backupSetId: (string) $backupSet->id, + ); + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_finished', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'backup_schedule_run_id' => $run->id, + 'status' => $status, + 'error_code' => $errorCode, + ], + ], + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: in_array($status, [BackupScheduleRun::STATUS_SUCCESS], true) ? 'success' : 'partial' + ); + } catch (\Throwable $throwable) { + $attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1; + $mapped = $errorMapper->map($throwable, $attempt, $this->tries); + + if ($mapped['shouldRetry']) { + $this->release($mapped['delay']); + + return; + } + + $this->finishRun( + run: $run, + schedule: $schedule, + status: BackupScheduleRun::STATUS_FAILED, + errorCode: $mapped['error_code'], + errorMessage: $mapped['error_message'], + summary: [ + 'exception' => get_class($throwable), + 'attempt' => $attempt, + ], + scheduleTimeService: $scheduleTimeService, + ); + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_failed', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'backup_schedule_run_id' => $run->id, + 'error_code' => $mapped['error_code'], + ], + ], + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'failed' + ); + } finally { + optional($lock)->release(); + } + } + + private function finishRun( + BackupScheduleRun $run, + BackupSchedule $schedule, + string $status, + ?string $errorCode, + ?string $errorMessage, + array $summary, + ScheduleTimeService $scheduleTimeService, + ?string $backupSetId = null, + ): void { + $nowUtc = CarbonImmutable::now('UTC'); + + $run->forceFill([ + 'status' => $status, + 'error_code' => $errorCode, + 'error_message' => $errorMessage, + 'summary' => Arr::wrap($summary), + 'finished_at' => $nowUtc, + 'backup_set_id' => $backupSetId, + ])->save(); + + $schedule->forceFill([ + 'last_run_at' => $nowUtc, + 'last_run_status' => $status, + 'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc), + ])->saveQuietly(); + + if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) { + Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id)); + } + } +} diff --git a/app/Models/BackupSchedule.php b/app/Models/BackupSchedule.php new file mode 100644 index 0000000..66e4e21 --- /dev/null +++ b/app/Models/BackupSchedule.php @@ -0,0 +1,34 @@ + 'boolean', + 'include_foundations' => 'boolean', + 'days_of_week' => 'array', + 'policy_types' => 'array', + 'last_run_at' => 'datetime', + 'next_run_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function runs(): HasMany + { + return $this->hasMany(BackupScheduleRun::class); + } +} diff --git a/app/Models/BackupScheduleRun.php b/app/Models/BackupScheduleRun.php new file mode 100644 index 0000000..76feb90 --- /dev/null +++ b/app/Models/BackupScheduleRun.php @@ -0,0 +1,48 @@ + 'datetime', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + 'summary' => 'array', + ]; + + public function schedule(): BelongsTo + { + return $this->belongsTo(BackupSchedule::class, 'backup_schedule_id'); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function backupSet(): BelongsTo + { + return $this->belongsTo(BackupSet::class); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 29c5af5..1294555 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -175,6 +175,16 @@ public function backupSets(): HasMany return $this->hasMany(BackupSet::class); } + public function backupSchedules(): HasMany + { + return $this->hasMany(BackupSchedule::class); + } + + public function backupScheduleRuns(): HasMany + { + return $this->hasMany(BackupScheduleRun::class); + } + public function policyVersions(): HasMany { return $this->hasMany(PolicyVersion::class); diff --git a/app/Notifications/BackupScheduleRunDispatchedNotification.php b/app/Notifications/BackupScheduleRunDispatchedNotification.php new file mode 100644 index 0000000..9690097 --- /dev/null +++ b/app/Notifications/BackupScheduleRunDispatchedNotification.php @@ -0,0 +1,55 @@ +, + * backup_schedule_run_ids?:array + * } $metadata + */ + public function __construct(public array $metadata) {} + + /** + * @return array + */ + public function via(object $notifiable): array + { + return ['database']; + } + + /** + * @return array + */ + public function toDatabase(object $notifiable): array + { + $trigger = (string) ($this->metadata['trigger'] ?? 'run_now'); + + $title = match ($trigger) { + 'retry' => 'Retry dispatched', + 'bulk_retry' => 'Retries dispatched', + 'bulk_run_now' => 'Runs dispatched', + default => 'Run dispatched', + }; + + $body = match ($trigger) { + 'bulk_retry', 'bulk_run_now' => 'Backup runs have been queued.', + default => 'A backup run has been queued.', + }; + + return [ + 'title' => $title, + 'body' => $body, + 'metadata' => $this->metadata, + ]; + } +} diff --git a/app/Policies/BackupSchedulePolicy.php b/app/Policies/BackupSchedulePolicy.php new file mode 100644 index 0000000..4fb4d78 --- /dev/null +++ b/app/Policies/BackupSchedulePolicy.php @@ -0,0 +1,46 @@ +tenantRole($tenant); + } + + public function viewAny(User $user): bool + { + return $this->resolveRole($user) !== null; + } + + public function view(User $user, BackupSchedule $schedule): bool + { + return $this->resolveRole($user) !== null; + } + + public function create(User $user): bool + { + return $this->resolveRole($user)?->canManageBackupSchedules() ?? false; + } + + public function update(User $user, BackupSchedule $schedule): bool + { + return $this->resolveRole($user)?->canManageBackupSchedules() ?? false; + } + + public function delete(User $user, BackupSchedule $schedule): bool + { + return $this->resolveRole($user)?->canManageBackupSchedules() ?? false; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9da238b..248c52c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,11 @@ namespace App\Providers; +use App\Models\BackupSchedule; use App\Models\Tenant; use App\Models\User; use App\Models\UserTenantPreference; +use App\Policies\BackupSchedulePolicy; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\NullGraphClient; @@ -23,6 +25,7 @@ use App\Services\Intune\WindowsUpdateRingNormalizer; use Filament\Events\TenantSet; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; @@ -102,5 +105,7 @@ public function boot(): void ], ); }); + + Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class); } } diff --git a/app/Rules/SupportedPolicyTypesRule.php b/app/Rules/SupportedPolicyTypesRule.php new file mode 100644 index 0000000..ca73105 --- /dev/null +++ b/app/Rules/SupportedPolicyTypesRule.php @@ -0,0 +1,22 @@ +ensureSupported($types); + } catch (InvalidPolicyTypeException $exception) { + $fail(sprintf('Unknown policy types: %s.', implode(', ', $exception->unknownPolicyTypes))); + } + } +} diff --git a/app/Services/BackupScheduling/BackupScheduleDispatcher.php b/app/Services/BackupScheduling/BackupScheduleDispatcher.php new file mode 100644 index 0000000..f0bd207 --- /dev/null +++ b/app/Services/BackupScheduling/BackupScheduleDispatcher.php @@ -0,0 +1,133 @@ +where('is_enabled', true) + ->whereHas('tenant', fn ($query) => $query->where('status', 'active')) + ->with('tenant'); + + if (is_array($tenantIdentifiers) && ! empty($tenantIdentifiers)) { + $schedulesQuery->whereIn('tenant_id', $this->resolveTenantIds($tenantIdentifiers)); + } + + $createdRuns = 0; + $skippedRuns = 0; + $scannedSchedules = 0; + + foreach ($schedulesQuery->cursor() as $schedule) { + $scannedSchedules++; + + $slot = $this->scheduleTimeService->nextRunFor($schedule, $nowUtc->subMinute()); + + if ($slot === null) { + $schedule->forceFill(['next_run_at' => null])->saveQuietly(); + + continue; + } + + if ($slot->greaterThan($nowUtc)) { + if (! $schedule->next_run_at || ! $schedule->next_run_at->equalTo($slot)) { + $schedule->forceFill(['next_run_at' => $slot])->saveQuietly(); + } + + continue; + } + + $run = null; + + try { + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $schedule->tenant_id, + 'scheduled_for' => $slot->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + } catch (QueryException $exception) { + // Idempotency: unique (backup_schedule_id, scheduled_for) + $skippedRuns++; + + continue; + } + + $createdRuns++; + + $this->auditLogger->log( + tenant: $schedule->tenant, + action: 'backup_schedule.run_dispatched', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $slot->toDateTimeString(), + ], + ], + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success' + ); + + $schedule->forceFill([ + 'next_run_at' => $this->scheduleTimeService->nextRunFor($schedule, $nowUtc), + ])->saveQuietly(); + + Bus::dispatch(new RunBackupScheduleJob($run->id)); + } + + return [ + 'created_runs' => $createdRuns, + 'skipped_runs' => $skippedRuns, + 'scanned_schedules' => $scannedSchedules, + ]; + } + + /** + * @param array $tenantIdentifiers + * @return array + */ + private function resolveTenantIds(array $tenantIdentifiers): array + { + $tenantIds = []; + + foreach ($tenantIdentifiers as $identifier) { + $tenant = Tenant::query() + ->where('status', 'active') + ->forTenant($identifier) + ->first(); + + if ($tenant) { + $tenantIds[] = $tenant->id; + } + } + + return array_values(array_unique($tenantIds)); + } +} diff --git a/app/Services/BackupScheduling/PolicyTypeResolver.php b/app/Services/BackupScheduling/PolicyTypeResolver.php new file mode 100644 index 0000000..6c11c98 --- /dev/null +++ b/app/Services/BackupScheduling/PolicyTypeResolver.php @@ -0,0 +1,55 @@ +findUnknown($types); + + if (! empty($unknown)) { + throw new InvalidPolicyTypeException($unknown); + } + } + + public function filterRuntime(array $types): array + { + $valid = $this->filter($types); + + return array_values($valid); + } + + public function resolveRuntime(array $types): array + { + $valid = $this->filter($types); + $unknown = $this->findUnknown($types); + + return [ + 'valid' => array_values($valid), + 'unknown' => array_values($unknown), + ]; + } + + protected function filter(array $types): array + { + $supported = $this->supportedPolicyTypes(); + + return array_values(array_intersect($types, $supported)); + } + + protected function findUnknown(array $types): array + { + $supported = $this->supportedPolicyTypes(); + + return array_values(array_diff($types, $supported)); + } +} diff --git a/app/Services/BackupScheduling/RunErrorMapper.php b/app/Services/BackupScheduling/RunErrorMapper.php new file mode 100644 index 0000000..613c023 --- /dev/null +++ b/app/Services/BackupScheduling/RunErrorMapper.php @@ -0,0 +1,86 @@ +status; + + if ($status === 401) { + return $this->final(self::ERROR_TOKEN_EXPIRED, $throwable->getMessage()); + } + + if ($status === 403) { + return $this->final(self::ERROR_PERMISSION_MISSING, $throwable->getMessage()); + } + + if ($status === 429) { + return $this->retry(self::ERROR_GRAPH_THROTTLE, $throwable->getMessage(), $attempt, $maxAttempts); + } + + if ($status === 503) { + return $this->retry(self::ERROR_GRAPH_UNAVAILABLE, $throwable->getMessage(), $attempt, $maxAttempts); + } + + return $this->retry(self::ERROR_UNKNOWN, $throwable->getMessage(), $attempt, $maxAttempts); + } + + return $this->retry(self::ERROR_UNKNOWN, $throwable->getMessage(), $attempt, $maxAttempts); + } + + /** + * @return array{shouldRetry: bool, delay: int, error_code: string, error_message: string, final_status: string} + */ + private function retry(string $code, string $message, int $attempt, int $maxAttempts): array + { + if ($attempt >= $maxAttempts) { + return $this->final($code, $message); + } + + $delays = [60, 300, 900]; + $delay = $delays[min($attempt - 1, count($delays) - 1)]; + + return [ + 'shouldRetry' => true, + 'delay' => $delay, + 'error_code' => $code, + 'error_message' => $message, + 'final_status' => 'failed', + ]; + } + + /** + * @return array{shouldRetry: bool, delay: int, error_code: string, error_message: string, final_status: string} + */ + private function final(string $code, string $message): array + { + return [ + 'shouldRetry' => false, + 'delay' => 0, + 'error_code' => $code, + 'error_message' => $message, + 'final_status' => 'failed', + ]; + } +} diff --git a/app/Services/BackupScheduling/ScheduleTimeService.php b/app/Services/BackupScheduling/ScheduleTimeService.php new file mode 100644 index 0000000..331af54 --- /dev/null +++ b/app/Services/BackupScheduling/ScheduleTimeService.php @@ -0,0 +1,88 @@ +timezone; + $cursor = $after?->copy()->timezone($timezone) ?? CarbonImmutable::now($timezone); + + if ($schedule->frequency === 'weekly') { + return $this->nextWeeklyRun($schedule, $cursor); + } + + return $this->nextDailyRun($schedule, $cursor); + } + + protected function nextDailyRun(BackupSchedule $schedule, CarbonImmutable $cursor): ?CarbonImmutable + { + $time = $schedule->time_of_day; + $attempts = 0; + + if ($cursor->format('H:i:s') >= $time) { + $cursor = $cursor->addDay(); + } + + while ($attempts++ < 14) { + $candidate = $this->buildLocalSlot($schedule, $cursor); + + if ($candidate) { + return $candidate; + } + + $cursor = $cursor->addDay(); + } + + return null; + } + + protected function nextWeeklyRun(BackupSchedule $schedule, CarbonImmutable $cursor): ?CarbonImmutable + { + $allowed = $schedule->days_of_week ?? []; + $allowed = array_filter($allowed, fn ($day) => is_numeric($day) && $day >= 1 && $day <= 7); + $allowed = array_values($allowed); + + if (empty($allowed)) { + return null; + } + + $attempts = 0; + + while ($attempts++ < 21) { + $dayOfWeek = $cursor->dayOfWeekIso; + + if (in_array($dayOfWeek, $allowed, true)) { + $candidate = $this->buildLocalSlot($schedule, $cursor); + + $cursorUtc = $cursor->copy()->timezone('UTC'); + + if ($candidate && $candidate->greaterThan($cursorUtc)) { + return $candidate; + } + } + + $cursor = $cursor->addDay()->startOfDay(); + } + + return null; + } + + protected function buildLocalSlot(BackupSchedule $schedule, CarbonImmutable $date): ?CarbonImmutable + { + $timezone = $schedule->timezone; + $time = $schedule->time_of_day; + $datePart = $date->format('Y-m-d'); + $candidate = CarbonImmutable::createFromFormat('Y-m-d H:i:s', "{$datePart} {$time}", $timezone); + + if (! $candidate || $candidate->format('H:i:s') !== $time) { + return null; + } + + return $candidate->startOfMinute()->timezone('UTC'); + } +} diff --git a/app/Support/TenantRole.php b/app/Support/TenantRole.php index db82a29..38c8a00 100644 --- a/app/Support/TenantRole.php +++ b/app/Support/TenantRole.php @@ -18,4 +18,23 @@ public function canSync(): bool self::Readonly => false, }; } + + public function canManageBackupSchedules(): bool + { + return match ($this) { + self::Owner, + self::Manager => true, + default => false, + }; + } + + public function canRunBackupSchedules(): bool + { + return match ($this) { + self::Owner, + self::Manager, + self::Operator => true, + self::Readonly => false, + }; + } } diff --git a/database/migrations/2026_01_05_011014_create_backup_schedules_table.php b/database/migrations/2026_01_05_011014_create_backup_schedules_table.php new file mode 100644 index 0000000..08bdf6e --- /dev/null +++ b/database/migrations/2026_01_05_011014_create_backup_schedules_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->boolean('is_enabled')->default(true); + $table->string('timezone')->default('UTC'); + $table->enum('frequency', ['daily', 'weekly']); + $table->time('time_of_day'); + $table->json('days_of_week')->nullable(); + $table->json('policy_types'); + $table->boolean('include_foundations')->default(true); + $table->integer('retention_keep_last')->default(30); + $table->dateTime('last_run_at')->nullable(); + $table->string('last_run_status')->nullable(); + $table->dateTime('next_run_at')->nullable(); + $table->timestamps(); + + $table->index(['tenant_id', 'is_enabled']); + $table->index('next_run_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('backup_schedules'); + } +}; diff --git a/database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php b/database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php new file mode 100644 index 0000000..edc1021 --- /dev/null +++ b/database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('backup_schedule_id')->constrained('backup_schedules')->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->dateTime('scheduled_for'); + $table->dateTime('started_at')->nullable(); + $table->dateTime('finished_at')->nullable(); + $table->enum('status', ['running', 'success', 'partial', 'failed', 'canceled', 'skipped']); + $table->json('summary')->nullable(); + $table->string('error_code')->nullable(); + $table->text('error_message')->nullable(); + $table->foreignId('backup_set_id')->nullable()->constrained('backup_sets')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['backup_schedule_id', 'scheduled_for']); + $table->index(['backup_schedule_id', 'scheduled_for']); + $table->index(['tenant_id', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('backup_schedule_runs'); + } +}; diff --git a/resources/views/filament/modals/backup-schedule-run-view.blade.php b/resources/views/filament/modals/backup-schedule-run-view.blade.php new file mode 100644 index 0000000..e1fa38c --- /dev/null +++ b/resources/views/filament/modals/backup-schedule-run-view.blade.php @@ -0,0 +1,48 @@ + +
+
+
+
Scheduled for
+
{{ optional($run->scheduled_for)->toDateTimeString() ?? '—' }}
+
+
+
Status
+
{{ $run->status ?? '—' }}
+
+
+ +
+
+
Started at
+
{{ optional($run->started_at)->toDateTimeString() ?? '—' }}
+
+
+
Finished at
+
{{ optional($run->finished_at)->toDateTimeString() ?? '—' }}
+
+
+ +
+
+
Error code
+
{{ $run->error_code ?: '—' }}
+
+
+
Backup set
+
{{ $run->backup_set_id ?: '—' }}
+
+
+ +
+
Error message
+
{{ $run->error_message ?: '—' }}
+
+ +
+
Summary
+
+
{{ json_encode($run->summary ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+
+
+
+
diff --git a/routes/console.php b/routes/console.php index 3c9adf1..f2ce44a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,10 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('tenantpilot:schedules:dispatch')->everyMinute(); diff --git a/specs/032-backup-scheduling-mvp/checklists/requirements.md b/specs/032-backup-scheduling-mvp/checklists/requirements.md index db99631..201a603 100644 --- a/specs/032-backup-scheduling-mvp/checklists/requirements.md +++ b/specs/032-backup-scheduling-mvp/checklists/requirements.md @@ -1,13 +1,13 @@ # Requirements Checklist (032) -- [ ] Tenant-scoped tables use `tenant_id` consistently. -- [ ] 1 Run = 1 BackupSet (no rolling reuse in MVP). -- [ ] Dispatcher is idempotent (unique schedule_id + scheduled_for). -- [ ] Concurrency lock prevents parallel runs per schedule. -- [ ] Run stores status + summary + error_code/error_message. -- [ ] UI shows schedule list + run history + link to backup set. -- [ ] Run now + Retry are permission-gated and write DB notifications. -- [ ] Audit logs are written for dispatcher, runs, and retention (tenant-scoped; no secrets). -- [ ] Retry/backoff policy implemented (no retry for 401/403). -- [ ] Retention keeps last N and soft-deletes older backup sets. -- [ ] Tests cover due-calculation, idempotency, job success/failure, retention. +- [X] Tenant-scoped tables use `tenant_id` consistently. (Data model section in spec.md documents tenant_id on `backup_schedules` and `backup_schedule_runs`.) +- [X] 1 Run = 1 BackupSet (no rolling reuse in MVP). (Definitions + Goals in spec.md state the MVP semantics explicitly.) +- [X] Dispatcher is idempotent (unique schedule_id + scheduled_for). (Requirements FR-002 + FR-007 + plan's idempotent dispatch constraint specify unique slots.) +- [X] Concurrency lock prevents parallel runs per schedule. (FR-008 and plan note per-schedule concurrency lock; tasks T024/Run job mention locking.) +- [X] Run stores status + summary + error_code/error_message. (FR-004 and data model show these fields exist in `backup_schedule_runs`.) +- [X] UI shows schedule list + run history + link to backup set. (UX-001/UX-002 in spec, tasks T014 / relation managers + UI doc.) +- [X] Run now + Retry are permission-gated and write DB notifications. (SEC-002 + tasks T031-T034 describe Filament actions + notifications.) +- [X] Audit logs are written for dispatcher, runs, and retention (tenant-scoped; no secrets). (SEC-003 plus tasks T026/T033/T034 mention audit logging.) +- [X] Retry/backoff policy implemented (no retry for 401/403). (NFR-003 and tasks T025 mention retry/backoff rules.) +- [X] Retention keeps last N and soft-deletes older backup sets. (FR-007 + tasks T033/T034 describe retention job & soft delete.) +- [X] Tests cover due-calculation, idempotency, job success/failure, retention. (Tasks T011-T037 include Pest tests for due calculation, idempotency, job outcomes, and retention.) diff --git a/specs/032-backup-scheduling-mvp/plan.md b/specs/032-backup-scheduling-mvp/plan.md index 6b13973..a2160a5 100644 --- a/specs/032-backup-scheduling-mvp/plan.md +++ b/specs/032-backup-scheduling-mvp/plan.md @@ -29,7 +29,7 @@ ## Constitution Check - Graph Abstraction & Contracts: PASS (sync uses `GraphClientInterface` via `PolicySyncService`; unknown policy types fail-safe; no hardcoded endpoints) - Least Privilege: PASS (authorization via TenantRole matrix; no new scopes required beyond existing backup/sync) - Spec-First Workflow: PASS (spec/plan/tasks/checklist in `specs/032-backup-scheduling-mvp/`) -- Quality Gates: PASS (tasks include Pest coverage and Pint) +- Quality Gates: PASS (tasks include Pest coverage per constitution and Pint) ## Project Structure diff --git a/specs/032-backup-scheduling-mvp/quickstart.md b/specs/032-backup-scheduling-mvp/quickstart.md index 2856358..c853444 100644 --- a/specs/032-backup-scheduling-mvp/quickstart.md +++ b/specs/032-backup-scheduling-mvp/quickstart.md @@ -29,6 +29,10 @@ ## Run the dispatcher manually (MVP) - `./vendor/bin/sail php artisan tenantpilot:schedules:dispatch` +Optional: limit dispatching to specific tenants: + +- `./vendor/bin/sail php artisan tenantpilot:schedules:dispatch --tenant=` + ## Run the Laravel scheduler Recommended operations model: @@ -56,7 +60,12 @@ ## Verify outcomes - In run history: verify status, duration, error_code/message - For successful/partial runs: verify a linked `BackupSet` exists +Retention + +- After a successful/partial run creates a `BackupSet`, the retention job runs asynchronously and soft-deletes older `BackupSet`s so only the last N (per schedule) remain. + ## Notes - Unknown `policy_types` cannot be saved; legacy DB values are handled fail-safe at runtime. - Scheduled runs do not notify a user; interactive actions (Run now / Retry) should persist a DB notification for the acting user. +- Run now / Retry actions are available for `operator`+ roles. diff --git a/specs/032-backup-scheduling-mvp/spec.md b/specs/032-backup-scheduling-mvp/spec.md index a61b306..45bfd5e 100644 --- a/specs/032-backup-scheduling-mvp/spec.md +++ b/specs/032-backup-scheduling-mvp/spec.md @@ -19,8 +19,10 @@ ## Goals ## Clarifications ### Session 2026-01-05 -- Q: Wie sollen wir mit `policy_types` umgehen, die nicht in `config('tenantpilot.supported_policy_types')` enthalten sind? → A: Beim Speichern hart validieren und ablehnen; zur Laufzeit defensiv re-checken (Legacy/DB), unknown types skippen und Run als `partial` markieren mit `error_code=UNKNOWN_POLICY_TYPE` und Liste betroffener Types. -- Q: Wenn zur Laufzeit alle `policy_types` unbekannt sind (0 valid types nach Skip) – welcher Status? → A: `skipped` (fail-safe). +- Q: Wie sollen wir mit `policy_types` umgehen, die nicht in `config('tenantpilot.supported_policy_types')` enthalten sind? + → A: Beim Speichern hart validieren und ablehnen; zur Laufzeit defensiv re-checken (Legacy/DB), unknown types skippen und Run als `partial` markieren mit `error_code=UNKNOWN_POLICY_TYPE` und Liste betroffener Types. +- Q: Wenn zur Laufzeit alle `policy_types` unbekannt sind (0 valid types nach Skip) – welcher Status? + → A: `skipped` (fail-safe). ## Non-Goals (MVP) - Kein Kalender-UI als Pflicht (kann später ergänzt werden). @@ -50,7 +52,7 @@ ### Functional Requirements - **FR-004**: Run schreibt `backup_schedule_runs` mit Status + Summary + Error-Codes. - **FR-005**: “Run now” erzeugt sofort einen Run (scheduled_for=now) und dispatcht Job. - **FR-006**: “Retry” erzeugt einen neuen Run für denselben Schedule. -- **FR-007**: Retention hält nur die letzten N Runs/BackupSets pro Schedule (soft delete BackupSets). +- **FR-007**: Retention hält nur die letzten N BackupSets pro Schedule (soft delete BackupSets). - **FR-008**: Concurrency: Pro Schedule darf nur ein Run gleichzeitig laufen. Wenn bereits ein Run läuft, wird ein neuer Run nicht parallel gestartet und stattdessen als `skipped` markiert (mit Fehlercode). ### UX Requirements (Filament) @@ -124,6 +126,5 @@ ## Acceptance Criteria - UI zeigt Last Run + Next Run + Run-History. - Run now startet sofort. - Fehlerfälle (Token/Permission/Throttle) werden als failed/partial markiert mit error_code. -- Unbekannte `policy_types` können nicht gespeichert werden; falls Legacy-Daten vorkommen, werden sie zur Laufzeit geskippt und der Run wird als `partial` markiert mit `error_code=UNKNOWN_POLICY_TYPE`. - Unbekannte `policy_types` können nicht gespeichert werden; falls Legacy-Daten vorkommen, werden sie zur Laufzeit geskippt: mit valid types → `partial`, ohne valid types → `skipped` (jeweils `error_code=UNKNOWN_POLICY_TYPE`). - Retention hält nur die letzten N BackupSets pro Schedule. diff --git a/specs/032-backup-scheduling-mvp/tasks.md b/specs/032-backup-scheduling-mvp/tasks.md index 61cef70..74145b2 100644 --- a/specs/032-backup-scheduling-mvp/tasks.md +++ b/specs/032-backup-scheduling-mvp/tasks.md @@ -1,42 +1,113 @@ +--- +description: "Task list for feature implementation" +--- + # Tasks: Backup Scheduling MVP (032) -**Date**: 2026-01-05 -**Input**: spec.md, plan.md +**Input**: Design documents from `specs/032-backup-scheduling-mvp/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md -## Phase 1: Spec & Setup -- [ ] T001 Create specs/032-backup-scheduling-mvp (spec/plan/tasks + checklist). +**Tests**: Required by constitution quality gate (Pest) even for MVP. -## Phase 2: Data Model -- [ ] T002 Add migrations: backup_schedules + backup_schedule_runs (tenant-scoped, indexes, unique slot). -- [ ] T003 Add models + relationships (Tenant->schedules, Schedule->runs, Run->backupSet). +## Format: `[ID] [P?] [Story] Description` -## Phase 3: Scheduling + Dispatch -- [ ] T004 Add command `tenantpilot:schedules:dispatch`. -- [ ] T005 Register scheduler to run every minute. -- [ ] T006 Implement due-calculation (timezone, daily/weekly) + next_run_at computation. -- [ ] T007 Implement idempotent run creation (unique slot) + cache lock. +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1/US2/US3) +- Every task description includes at least one explicit file path -## Phase 4: Jobs -- [ ] T008 Implement `RunBackupScheduleJob` (sync -> select policy IDs -> create backup set -> update run + schedule). -- [ ] T009 Implement `ApplyBackupScheduleRetentionJob` (keep last N, soft-delete backup sets). -- [ ] T010 Add error mapping to `error_code` (TOKEN_EXPIRED, PERMISSION_MISSING, GRAPH_THROTTLE, UNKNOWN). - - [ ] T021 Add audit logging for dispatcher/run/retention (tenant-scoped; no secrets). - - [ ] T022 Implement retry/backoff strategy for `RunBackupScheduleJob` (no retry on 401/403). +## User Stories -## Phase 5: Filament UI -- [ ] T011 Add `BackupScheduleResource` (tenant-scoped): CRUD + enable/disable. -- [ ] T012 Add Runs UI (relation manager or resource) with details + link to BackupSet. -- [ ] T013 Add actions: Run now + Retry (permission-gated); notifications persisted to DB. - - [ ] T023 Wire authorization to TenantRole (readonly/operator/manager/owner) for schedule CRUD and run actions. +- **US1 (P1)**: Manage tenant-scoped backup schedules (CRUD, validation). +- **US2 (P1)**: Dispatch + execute runs idempotently (queue/scheduler), write runs + audit logs. +- **US3 (P2)**: View run history, run-now/retry actions, retention keep-last-N. -## Phase 6: Tests -- [ ] T014 Unit: due-calculation + next_run_at. -- [ ] T015 Feature: dispatcher idempotency (unique slot); lock behavior. -- [ ] T016 Job-level: successful run creates backup set, updates run/schedule (Graph mocked). -- [ ] T017 Job-level: token/permission/throttle errors map to error_code and status. -- [ ] T018 Retention: keeps last N and deletes older backup sets. - - [ ] T024 Tests: audit logs written (run success + retention delete) and retry policy behavior. +--- + +## Phase 1: Setup (Shared Infrastructure) + +- [X] T001 [P] [US2] Review existing sync + backup APIs in app/Services/Intune/PolicySyncService.php and app/Services/Intune/BackupService.php +- [X] T002 [P] [US1] Confirm supported policy types config key/shape in config/tenantpilot.php (source of truth for `policy_types`) +- [X] T003 [P] [US2] Confirm audit logging primitives in app/Services/Intune/AuditLogger.php and app/Models/AuditLog.php + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +- [X] T004 [US1] Add migrations for schedules/runs in database/migrations/*_create_backup_schedules_table.php and database/migrations/*_create_backup_schedule_runs_table.php (tenant FKs, jsonb, indexes, unique (backup_schedule_id, scheduled_for)) +- [X] T005 [P] [US1] Add model app/Models/BackupSchedule.php (casts, relationships) +- [X] T006 [P] [US2] Add model app/Models/BackupScheduleRun.php (casts, relationships, status + error fields) +- [X] T007 [P] [US1] Add tenant relationships in app/Models/Tenant.php (backupSchedules(), backupScheduleRuns()) +- [X] T008 [P] [US1] Add TenantRole helpers in app/Support/TenantRole.php for SEC-002 (canManageBackupSchedules(), canRunBackupSchedules()) +- [X] T009 [US2] Implement next-run + slot calculation in app/Services/BackupScheduling/ScheduleTimeService.php (UTC minute-slot, DST rules, no catch-up) +- [X] T010 [US2] Implement policy type validation/filtering in app/Services/BackupScheduling/PolicyTypeResolver.php (validate vs config/tenantpilot.php; runtime filtering for legacy DB) + +--- + +## Phase 3: User Story 1 - Manage Schedules (Priority: P1) + +### Tests (Pest) + +- [X] T011 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleCrudTest.php (tenant scoping + manager/owner CRUD) +- [X] T012 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleValidationTest.php (weekly days_of_week rules; `policy_types` hard validation) +- [X] T013 [P] [US1] Add unit test tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php (next_run_at + DST invalid/ambiguous behavior) + +### Implementation + +- [X] T014 [US1] Implement resource app/Filament/Resources/BackupScheduleResource.php (list columns per UX-001; create/edit form fields) +- [X] T015 [US1] Enforce authorization in app/Filament/Resources/BackupScheduleResource.php using app/Support/TenantRole.php (SEC-002) +- [X] T016 [US1] Persist next_run_at updates via app/Services/BackupScheduling/ScheduleTimeService.php (on create/update) +- [X] T017 [US1] Validate `policy_types` via app/Services/BackupScheduling/PolicyTypeResolver.php (reject unknown keys at save-time) + +--- + +## Phase 4: User Story 2 - Dispatch & Execute Runs (Priority: P1) + +### Tests (Pest) + +- [X] T018 [P] [US2] Add feature test tests/Feature/BackupScheduling/DispatchIdempotencyTest.php (same slot dispatch twice → one run) +- [X] T019 [P] [US2] Add feature test tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php (success/partial/skipped outcomes + backup_set linkage) +- [X] T020 [P] [US2] Add feature test tests/Feature/BackupScheduling/RunErrorMappingTest.php (retry/backoff vs no-retry mapping) + +### Implementation + +- [X] T021 [P] [US2] Add command app/Console/Commands/TenantpilotDispatchBackupSchedules.php (tenantpilot:schedules:dispatch) +- [X] T022 [US2] Register scheduler entry in routes/console.php (every minute) +- [X] T023 [US2] Implement dispatcher service app/Services/BackupScheduling/BackupScheduleDispatcher.php (find due schedules, create run, dispatch job) +- [X] T024 [US2] Implement job app/Jobs/RunBackupScheduleJob.php (lock per schedule, sync via app/Services/Intune/PolicySyncService.php, create backup set via app/Services/Intune/BackupService.php, update run + schedule fields) +- [X] T025 [US2] Implement retry/backoff in app/Jobs/RunBackupScheduleJob.php (429/503 backoff; 401/403 no retry; unknown limited retries) +- [X] T026 [US2] Write audit logs (dispatch/run start/run end) using app/Services/Intune/AuditLogger.php from app/Services/BackupScheduling/BackupScheduleDispatcher.php and app/Jobs/RunBackupScheduleJob.php +- [X] T027 [US2] Implement unknown policy types runtime behavior in app/Jobs/RunBackupScheduleJob.php using app/Services/BackupScheduling/PolicyTypeResolver.php (≥1 valid → partial; 0 valid → skipped; error_code UNKNOWN_POLICY_TYPE + list in run summary) + +--- + +## Phase 5: User Story 3 - Run History, Actions, Retention (Priority: P2) + +### Tests (Pest) + +- [X] T028 [P] [US3] Add feature test tests/Feature/BackupScheduling/RunNowRetryActionsTest.php (operator+ allowed; DB notification persisted) +- [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) + +### Implementation + +- [X] T030 [US3] Add runs RelationManager app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php (UX-002 fields + tenant scoping) +- [X] T031 [US3] Add Run now / Retry actions in app/Filament/Resources/BackupScheduleResource.php (SEC-002 gating via app/Support/TenantRole.php) +- [X] T032 [US3] Persist database notifications for interactive actions in app/Filament/Resources/BackupScheduleResource.php +- [X] T033 [US3] Implement retention job app/Jobs/ApplyBackupScheduleRetentionJob.php (soft-delete old backup sets; write audit log) +- [X] T034 [US3] Dispatch retention job from app/Jobs/RunBackupScheduleJob.php after completion + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T035 [P] [US2] Validate operational steps in specs/032-backup-scheduling-mvp/quickstart.md (queue + scheduler + manual dispatch) +- [X] T036 [P] [US2] Run formatter on touched files using vendor/bin/pint --dirty +- [X] T037 [P] [US2] Run targeted tests using vendor/bin/sail php artisan test tests/Feature/BackupScheduling tests/Unit/BackupScheduling + +--- + +## Dependencies & Execution Order + +Setup (Phase 1) → Foundational (Phase 2) → US1 (P1) → US2 (P1) → US3 (P2) → Polish -## Phase 7: Verification -- [ ] T019 Run targeted tests (Pest). -- [ ] T020 Run Pint (`./vendor/bin/pint --dirty`). diff --git a/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php b/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php new file mode 100644 index 0000000..a5b1720 --- /dev/null +++ b/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php @@ -0,0 +1,67 @@ +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' => 2, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $sets = collect(range(1, 5))->map(function (int $i) use ($tenant): BackupSet { + return BackupSet::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Set '.$i, + 'status' => 'completed', + 'item_count' => 0, + 'completed_at' => now()->subMinutes(10 - $i), + ]); + }); + + // Oldest → newest + $scheduledFor = now('UTC')->startOfMinute()->subMinutes(10); + foreach ($sets as $set) { + BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => $scheduledFor, + 'status' => BackupScheduleRun::STATUS_SUCCESS, + 'summary' => ['policies_total' => 0, 'policies_backed_up' => 0, 'errors_count' => 0], + 'backup_set_id' => $set->id, + ]); + $scheduledFor = $scheduledFor->addMinute(); + } + + ApplyBackupScheduleRetentionJob::dispatchSync($schedule->id); + + $kept = $sets->take(-2); + $deleted = $sets->take(3); + + foreach ($kept as $set) { + $this->assertDatabaseHas('backup_sets', [ + 'id' => $set->id, + 'deleted_at' => null, + ]); + } + + foreach ($deleted as $set) { + $this->assertSoftDeleted('backup_sets', ['id' => $set->id]); + } +}); diff --git a/tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php b/tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php new file mode 100644 index 0000000..f3817d8 --- /dev/null +++ b/tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php @@ -0,0 +1,89 @@ +create([ + 'tenant_id' => $tenant->id, + 'name' => 'Delete 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' => 'Delete 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, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_delete', collect([$scheduleA, $scheduleB])) + ->assertHasNoTableBulkActionErrors(); + + expect(BackupSchedule::query()->where('tenant_id', $tenant->id)->count()) + ->toBe(0); +}); + +test('operator cannot bulk delete backup schedules', function () { + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $scheduleA = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Keep 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' => 'Keep 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, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + try { + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_delete', collect([$scheduleA, $scheduleB])); + } catch (\Throwable) { + // Action should be hidden/blocked for operator users. + } + + expect(BackupSchedule::query()->where('tenant_id', $tenant->id)->count()) + ->toBe(2); +}); diff --git a/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php b/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php new file mode 100644 index 0000000..90c9f73 --- /dev/null +++ b/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php @@ -0,0 +1,93 @@ +create(); + + createUserWithTenant(tenant: $tenantB, user: $user, role: 'manager'); + + BackupSchedule::query()->create([ + 'tenant_id' => $tenantA->id, + 'name' => 'Tenant A schedule', + '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, + ]); + + BackupSchedule::query()->create([ + 'tenant_id' => $tenantB->id, + 'name' => 'Tenant B schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceCompliancePolicy'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + + $this->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($tenantA))) + ->assertOk() + ->assertSee('Tenant A schedule') + ->assertSee('Device Configuration') + ->assertDontSee('Tenant B schedule'); +}); + +test('backup schedules pages return 404 for unauthorized tenant', function () { + [$user] = createUserWithTenant(role: 'manager'); + $unauthorizedTenant = Tenant::factory()->create(); + + $this->actingAs($user) + ->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($unauthorizedTenant))) + ->assertNotFound(); +}); + +test('manager can create and edit backup schedules via filament', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(CreateBackupSchedule::class) + ->fillForm([ + 'name' => 'Daily at 10', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00', + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $schedule = BackupSchedule::query()->where('tenant_id', $tenant->id)->first(); + expect($schedule)->not->toBeNull(); + expect($schedule->next_run_at)->not->toBeNull(); + + Livewire::test(EditBackupSchedule::class, ['record' => $schedule->getRouteKey()]) + ->fillForm([ + 'name' => 'Daily at 11', + ]) + ->call('save') + ->assertHasNoFormErrors(); + + $schedule->refresh(); + expect($schedule->name)->toBe('Daily at 11'); +}); diff --git a/tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php b/tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php new file mode 100644 index 0000000..ff8d995 --- /dev/null +++ b/tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php @@ -0,0 +1,53 @@ +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, + ]); + + $backupSet = BackupSet::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Set 174', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $run = BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => now('UTC')->startOfMinute()->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_SUCCESS, + 'summary' => [ + 'policies_total' => 7, + 'policies_backed_up' => 7, + 'errors_count' => 0, + ], + 'error_code' => null, + 'error_message' => null, + 'backup_set_id' => $backupSet->id, + ]); + + $this->actingAs($user); + + $html = view('filament.modals.backup-schedule-run-view', ['run' => $run])->render(); + + expect($html)->toContain('Scheduled for'); + expect($html)->toContain('Status'); + expect($html)->toContain('Summary'); + expect($html)->toContain((string) $backupSet->id); +}); diff --git a/tests/Feature/BackupScheduling/BackupScheduleValidationTest.php b/tests/Feature/BackupScheduling/BackupScheduleValidationTest.php new file mode 100644 index 0000000..623effa --- /dev/null +++ b/tests/Feature/BackupScheduling/BackupScheduleValidationTest.php @@ -0,0 +1,48 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(CreateBackupSchedule::class) + ->fillForm([ + 'name' => 'Weekly schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'weekly', + 'time_of_day' => '10:00', + 'days_of_week' => [], + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]) + ->call('create') + ->assertHasFormErrors(['days_of_week']); +}); + +test('unknown policy types are rejected at save time', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(CreateBackupSchedule::class) + ->fillForm([ + 'name' => 'Invalid policy type schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00', + 'policy_types' => ['definitelyNotARealPolicyType'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]) + ->call('create') + ->assertHasFormErrors(['policy_types']); +}); diff --git a/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php new file mode 100644 index 0000000..8ab996b --- /dev/null +++ b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php @@ -0,0 +1,40 @@ +actingAs($user); + + 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, + ]); + + Bus::fake(); + + $dispatcher = app(BackupScheduleDispatcher::class); + + $dispatcher->dispatchDue([$tenant->external_id]); + $dispatcher->dispatchDue([$tenant->external_id]); + + expect(BackupScheduleRun::query()->count())->toBe(1); + + Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1); +}); diff --git a/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php new file mode 100644 index 0000000..ffed21c --- /dev/null +++ b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php @@ -0,0 +1,123 @@ +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, + ]); + + $run = BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + ]); + + app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService + { + public function __construct() {} + + public function syncPoliciesWithReport($tenant, ?array $supportedTypes = null): array + { + return ['synced' => [], 'failures' => []]; + } + }); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + 'item_count' => 0, + ]); + + app()->bind(BackupService::class, fn () => new class($backupSet) extends BackupService + { + public function __construct(private readonly BackupSet $backupSet) {} + + public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, bool $includeAssignments = false, bool $includeScopeTags = false, bool $includeFoundations = false): BackupSet + { + return $this->backupSet; + } + }); + + Cache::flush(); + + (new RunBackupScheduleJob($run->id))->handle( + app(PolicySyncService::class), + app(BackupService::class), + app(\App\Services\BackupScheduling\PolicyTypeResolver::class), + app(\App\Services\BackupScheduling\ScheduleTimeService::class), + app(\App\Services\Intune\AuditLogger::class), + app(\App\Services\BackupScheduling\RunErrorMapper::class), + ); + + $run->refresh(); + expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS); + expect($run->backup_set_id)->toBe($backupSet->id); +}); + +it('skips runs when all policy types are unknown', 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' => ['definitelyNotARealPolicyType'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => null, + ]); + + $run = BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + ]); + + Cache::flush(); + + (new RunBackupScheduleJob($run->id))->handle( + app(PolicySyncService::class), + app(BackupService::class), + app(\App\Services\BackupScheduling\PolicyTypeResolver::class), + app(\App\Services\BackupScheduling\ScheduleTimeService::class), + app(\App\Services\Intune\AuditLogger::class), + app(\App\Services\BackupScheduling\RunErrorMapper::class), + ); + + $run->refresh(); + expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED); + expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE'); + expect($run->backup_set_id)->toBeNull(); +}); diff --git a/tests/Feature/BackupScheduling/RunErrorMappingTest.php b/tests/Feature/BackupScheduling/RunErrorMappingTest.php new file mode 100644 index 0000000..097f092 --- /dev/null +++ b/tests/Feature/BackupScheduling/RunErrorMappingTest.php @@ -0,0 +1,42 @@ +map(new GraphException('auth failed', 401), attempt: 1, maxAttempts: 3); + + expect($mapped['shouldRetry'])->toBeFalse(); + expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_TOKEN_EXPIRED); +}); + +it('marks 403 as permission missing without retry', function () { + $mapper = app(RunErrorMapper::class); + + $mapped = $mapper->map(new GraphException('forbidden', 403), attempt: 1, maxAttempts: 3); + + expect($mapped['shouldRetry'])->toBeFalse(); + expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_PERMISSION_MISSING); +}); + +it('retries throttling with backoff', function () { + $mapper = app(RunErrorMapper::class); + + $mapped = $mapper->map(new GraphException('throttled', 429), attempt: 1, maxAttempts: 3); + + expect($mapped['shouldRetry'])->toBeTrue(); + expect($mapped['delay'])->toBe(60); + expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_GRAPH_THROTTLE); +}); + +it('retries service unavailable with backoff', function () { + $mapper = app(RunErrorMapper::class); + + $mapped = $mapper->map(new GraphException('unavailable', 503), attempt: 2, maxAttempts: 3); + + expect($mapped['shouldRetry'])->toBeTrue(); + expect($mapped['delay'])->toBe(300); + expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_GRAPH_UNAVAILABLE); +}); diff --git a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php new file mode 100644 index 0000000..276fcf4 --- /dev/null +++ b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php @@ -0,0 +1,205 @@ +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, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableAction('runNow', $schedule); + + 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(); + + Queue::assertPushed(RunBackupScheduleJob::class); + + $this->assertDatabaseCount('notifications', 1); + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->id, + 'notifiable_type' => User::class, + ]); +}); + +test('operator can retry and it persists a database notification', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $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, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableAction('retry', $schedule); + + expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + ->toBe(1); + + Queue::assertPushed(RunBackupScheduleJob::class); + $this->assertDatabaseCount('notifications', 1); +}); + +test('readonly cannot dispatch run now or retry', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $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, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + try { + Livewire::test(ListBackupSchedules::class) + ->callTableAction('runNow', $schedule); + } catch (\Throwable) { + // Action should be hidden/blocked for readonly users. + } + + try { + Livewire::test(ListBackupSchedules::class) + ->callTableAction('retry', $schedule); + } catch (\Throwable) { + // Action should be hidden/blocked for readonly users. + } + + expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + ->toBe(0); +}); + +test('operator can bulk run now and it persists a database notification', 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, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_run_now', collect([$scheduleA, $scheduleB])); + + expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) + ->toBe(2); + + Queue::assertPushed(RunBackupScheduleJob::class, 2); + $this->assertDatabaseCount('notifications', 1); +}); + +test('operator can bulk retry and it persists a database notification', 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, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB])); + + expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) + ->toBe(2); + + Queue::assertPushed(RunBackupScheduleJob::class, 2); + $this->assertDatabaseCount('notifications', 1); +}); diff --git a/tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php b/tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php new file mode 100644 index 0000000..e73b14c --- /dev/null +++ b/tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php @@ -0,0 +1,45 @@ +forceFill([ + 'frequency' => 'daily', + 'timezone' => 'Europe/Berlin', + 'time_of_day' => '02:30:00', + 'days_of_week' => [], + ]); + + $service = app(ScheduleTimeService::class); + + // On 2026-03-29 in Europe/Berlin, the clock jumps from 02:00 to 03:00 (02:30 is nonexistent). + // Using an "after" cursor later than 02:30 on the previous day forces the candidate day to be 2026-03-29. + $after = CarbonImmutable::create(2026, 3, 28, 3, 0, 0, 'Europe/Berlin'); + + $next = $service->nextRunFor($schedule, $after); + + expect($next)->not->toBeNull(); + expect($next->timezone('UTC')->format('Y-m-d H:i:s'))->toBe('2026-03-30 00:30:00'); +}); + +it('returns null for weekly schedules without allowed days', function () { + $schedule = new BackupSchedule; + $schedule->forceFill([ + 'frequency' => 'weekly', + 'timezone' => 'UTC', + 'time_of_day' => '10:00:00', + 'days_of_week' => [], + ]); + + $service = app(ScheduleTimeService::class); + + $next = $service->nextRunFor($schedule, CarbonImmutable::create(2026, 1, 5, 0, 0, 0, 'UTC')); + + expect($next)->toBeNull(); +});