diff --git a/i18n/default_de.json b/i18n/default_de.json index bc6c2fd..35753cb 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -1150,6 +1150,7 @@ "Can run scheduled jobs manually": "Kann geplante Aufgaben manuell ausführen", "Scheduled job not found": "Geplante Aufgabe nicht gefunden", "Scheduled job is not registered": "Geplante Aufgabe ist nicht registriert", + "This scheduled job is no longer registered and cannot be edited. It has been superseded by a newer version.": "Diese geplante Aufgabe ist nicht mehr registriert und kann nicht bearbeitet werden. Sie wurde durch eine neuere Version ersetzt.", "Scheduled job could not be saved": "Geplante Aufgabe konnte nicht gespeichert werden", "Scheduled job saved": "Geplante Aufgabe gespeichert", "Edit scheduled job": "Geplante Aufgabe bearbeiten", diff --git a/i18n/default_en.json b/i18n/default_en.json index 6187170..ca03ed0 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -1150,6 +1150,7 @@ "Can run scheduled jobs manually": "Can run scheduled jobs manually", "Scheduled job not found": "Scheduled job not found", "Scheduled job is not registered": "Scheduled job is not registered", + "This scheduled job is no longer registered and cannot be edited. It has been superseded by a newer version.": "This scheduled job is no longer registered and cannot be edited. It has been superseded by a newer version.", "Scheduled job could not be saved": "Scheduled job could not be saved", "Scheduled job saved": "Scheduled job saved", "Edit scheduled job": "Edit scheduled job", diff --git a/lib/Repository/Scheduler/ScheduledJobRepository.php b/lib/Repository/Scheduler/ScheduledJobRepository.php index d5dc21a..987a2aa 100644 --- a/lib/Repository/Scheduler/ScheduledJobRepository.php +++ b/lib/Repository/Scheduler/ScheduledJobRepository.php @@ -240,6 +240,22 @@ class ScheduledJobRepository implements ScheduledJobRepositoryInterface return $updated !== false; } + public function deleteOrphanedJobs(array $validKeys): int + { + $validKeys = array_filter(array_map('trim', $validKeys), static fn (string $k): bool => $k !== ''); + if ($validKeys === []) { + return 0; + } + + $placeholders = implode(',', array_fill(0, count($validKeys), '?')); + $affected = DB::update( + "DELETE FROM scheduled_jobs WHERE job_key NOT IN ({$placeholders})", + ...array_map('strval', $validKeys) + ); + + return (int) $affected; + } + private function normalizeRow(mixed $row): ?array { if (!is_array($row)) { diff --git a/lib/Repository/Scheduler/ScheduledJobRepositoryInterface.php b/lib/Repository/Scheduler/ScheduledJobRepositoryInterface.php index 0e0071c..eba515c 100644 --- a/lib/Repository/Scheduler/ScheduledJobRepositoryInterface.php +++ b/lib/Repository/Scheduler/ScheduledJobRepositoryInterface.php @@ -28,4 +28,7 @@ interface ScheduledJobRepositoryInterface ?string $errorCode, ?string $errorMessage ): bool; + + /** Delete jobs whose job_key is not in the given list of valid keys. Returns affected row count. */ + public function deleteOrphanedJobs(array $validKeys): int; } diff --git a/lib/Service/Scheduler/ScheduledJobService.php b/lib/Service/Scheduler/ScheduledJobService.php index 380bb8a..2a85f76 100644 --- a/lib/Service/Scheduler/ScheduledJobService.php +++ b/lib/Service/Scheduler/ScheduledJobService.php @@ -42,6 +42,13 @@ class ScheduledJobService } $this->scheduledJobRepository->updateJobMeta((int) $existing['id'], $normalized); } + + $this->scheduledJobRepository->deleteOrphanedJobs(array_keys($definitions)); + } + + public function isRegistered(string $jobKey): bool + { + return $this->scheduledJobRegistry->get($jobKey) !== null; } public function listPaged(array $filters): array diff --git a/pages/admin/scheduled-jobs/edit($id).php b/pages/admin/scheduled-jobs/edit($id).php index 2f51f34..ae66d15 100644 --- a/pages/admin/scheduled-jobs/edit($id).php +++ b/pages/admin/scheduled-jobs/edit($id).php @@ -20,6 +20,8 @@ if (!$job) { Router::redirect('admin/scheduled-jobs'); } +$isRegistered = $scheduledJobService->isRegistered((string) ($job['job_key'] ?? '')); + $currentUserId = (int) ($session['user']['id'] ?? 0); $authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class); $canManage = $authorizationService->authorize(\MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_MANAGE, [ @@ -39,6 +41,12 @@ $form = [ ]; $errorBag = formErrors(); +if (!$isRegistered) { + $canManage = false; + $canRunNow = false; + $errorBag->addGlobal(t('This scheduled job is no longer registered and cannot be edited. It has been superseded by a newer version.')); +} + if (requestInput()->method() === 'POST') { if (!$canManage) { Guard::deny(); diff --git a/tests/Service/Scheduler/ScheduledJobServiceTest.php b/tests/Service/Scheduler/ScheduledJobServiceTest.php index 308741e..d806e91 100644 --- a/tests/Service/Scheduler/ScheduledJobServiceTest.php +++ b/tests/Service/Scheduler/ScheduledJobServiceTest.php @@ -102,6 +102,39 @@ class ScheduledJobServiceTest extends TestCase $service->ensureSystemJobs(); } + public function testEnsureSystemJobsDisablesOrphanedJobs(): void + { + $jobRepository = $this->createMock(ScheduledJobRepository::class); + $runRepository = $this->createMock(ScheduledJobRunRepository::class); + $registry = $this->createMock(ScheduledJobRegistry::class); + $calculator = new ScheduleCalculator(); + + $registry->expects($this->once())->method('definitions')->willReturn([ + 'system_audit_purge' => [ + 'label' => 'System audit log cleanup', + 'description' => 'Purges system audit log entries', + 'default_enabled' => 1, + 'default_timezone' => 'UTC', + 'default_schedule_type' => 'daily', + 'default_schedule_interval' => 1, + 'default_schedule_time' => '03:00', + 'default_schedule_weekdays_csv' => null, + 'default_catchup_once' => 1, + 'allowed_schedule_types' => ['daily', 'weekly'], + ], + ]); + $jobRepository->method('findByKey')->willReturn(null); + $jobRepository->method('create')->willReturn(1); + + $jobRepository->expects($this->once()) + ->method('deleteOrphanedJobs') + ->with(['system_audit_purge']) + ->willReturn(1); + + $service = new ScheduledJobService($jobRepository, $runRepository, $registry, $calculator); + $service->ensureSystemJobs(); + } + public function testUpdateFromAdminReturnsValidationErrorForWeeklyWithoutWeekdays(): void { $jobRepository = $this->createMock(ScheduledJobRepository::class);