diff --git a/lib/Service/Scheduler/SchedulerRunService.php b/lib/Service/Scheduler/SchedulerRunService.php index daea0f8..0ff686c 100644 --- a/lib/Service/Scheduler/SchedulerRunService.php +++ b/lib/Service/Scheduler/SchedulerRunService.php @@ -8,6 +8,7 @@ use MintyPHP\Domain\Taxonomy\SchedulerRuntimeResult; use MintyPHP\Repository\Scheduler\ScheduledJobRepositoryInterface; use MintyPHP\Repository\Scheduler\ScheduledJobRunRepositoryInterface; use MintyPHP\Repository\Scheduler\SchedulerRuntimeRepositoryInterface; +use MintyPHP\App\Module\ModuleEventDispatcher; use MintyPHP\Repository\Support\DatabaseSessionRepository; use MintyPHP\Repository\Support\RepoQuery; use MintyPHP\Service\Audit\AuditRecorderInterface; @@ -32,7 +33,8 @@ class SchedulerRunService private readonly ScheduledJobRegistry $scheduledJobRegistry, private readonly ScheduleCalculator $scheduleCalculator, private readonly DatabaseSessionRepository $databaseSessionRepository, - private readonly AuditRecorderInterface $systemAuditService + private readonly AuditRecorderInterface $systemAuditService, + private readonly ?ModuleEventDispatcher $eventDispatcher = null ) { } @@ -265,6 +267,10 @@ class SchedulerRunService $run = ['status' => $status, 'error_code' => $errorCode, 'error_message' => $errorMessage]; $this->recordSchedulerJobEvent($jobId, (string) ($job['job_key'] ?? ''), $triggerType, $actorUserId, $run, $durationMs); + if ($status === ScheduledJobRunStatus::Failed->value) { + $this->dispatchJobFailedEvent($job, $errorCode, $errorMessage, $triggerType); + } + return $run; } @@ -410,6 +416,26 @@ class SchedulerRunService ); } + private function dispatchJobFailedEvent(array $job, ?string $errorCode, ?string $errorMessage, string $triggerType): void + { + if (!$this->eventDispatcher) { + return; + } + + try { + $this->eventDispatcher->dispatch('scheduler.job_failed', [ + 'job_id' => (int) ($job['id'] ?? 0), + 'job_key' => (string) ($job['job_key'] ?? ''), + 'label' => (string) ($job['label'] ?? ''), + 'error_code' => $errorCode, + 'error_message' => $errorMessage, + 'trigger_type' => $triggerType, + ]); + } catch (\Throwable) { + // fail-open: notification failure must not affect scheduler + } + } + private function recordAudit(string $eventType, string $outcome, array $context): void { try { diff --git a/lib/Service/Scheduler/SchedulerServicesFactory.php b/lib/Service/Scheduler/SchedulerServicesFactory.php index 6420155..1506338 100644 --- a/lib/Service/Scheduler/SchedulerServicesFactory.php +++ b/lib/Service/Scheduler/SchedulerServicesFactory.php @@ -3,6 +3,7 @@ namespace MintyPHP\Service\Scheduler; use MintyPHP\App\AppContainer; +use MintyPHP\App\Module\ModuleEventDispatcher; use MintyPHP\Repository\Scheduler\ScheduledJobRepositoryInterface; use MintyPHP\Repository\Scheduler\ScheduledJobRunRepositoryInterface; use MintyPHP\Repository\Scheduler\SchedulerRuntimeRepositoryInterface; @@ -47,7 +48,8 @@ class SchedulerServicesFactory $this->getScheduledJobRegistry(), $this->getScheduleCalculator(), $this->getDatabaseSessionRepository(), - $this->getAuditRecorder() + $this->getAuditRecorder(), + $this->getEventDispatcher() ); } @@ -99,4 +101,13 @@ class SchedulerServicesFactory return $this->auditRecorder; } + private function getEventDispatcher(): ?ModuleEventDispatcher + { + if ($this->appContainer->has(ModuleEventDispatcher::class)) { + $dispatcher = $this->appContainer->get(ModuleEventDispatcher::class); + return $dispatcher instanceof ModuleEventDispatcher ? $dispatcher : null; + } + + return null; + } } diff --git a/modules/audit/module.php b/modules/audit/module.php index 61d9fa6..36a90c9 100644 --- a/modules/audit/module.php +++ b/modules/audit/module.php @@ -123,7 +123,7 @@ return [ 'scheduler_jobs' => [ [ - 'job_key' => 'audit_system_purge', + 'job_key' => 'system_audit_purge', 'handler' => \MintyPHP\Module\Audit\Handler\SystemAuditPurgeJobHandler::class, 'label' => 'System audit log cleanup', 'description' => 'Purges system audit log entries older than the configured retention period', diff --git a/modules/notifications/i18n/default_de.json b/modules/notifications/i18n/default_de.json index 1aa9fd7..45075ca 100644 --- a/modules/notifications/i18n/default_de.json +++ b/modules/notifications/i18n/default_de.json @@ -12,5 +12,7 @@ "User activated: %s": "Benutzer aktiviert: %s", "User deactivated: %s": "Benutzer deaktiviert: %s", "User assignments updated: %s": "Benutzer-Zuweisungen aktualisiert: %s", - "Tenant, role, or department assignments were changed.": "Mandanten-, Rollen- oder Abteilungszuweisungen wurden geändert." + "Tenant, role, or department assignments were changed.": "Mandanten-, Rollen- oder Abteilungszuweisungen wurden geändert.", + "Scheduled job failed: %s": "Geplante Aufgabe fehlgeschlagen: %s", + "Error: %s": "Fehler: %s" } diff --git a/modules/notifications/i18n/default_en.json b/modules/notifications/i18n/default_en.json index 76bcf93..d14c2f7 100644 --- a/modules/notifications/i18n/default_en.json +++ b/modules/notifications/i18n/default_en.json @@ -12,5 +12,7 @@ "User activated: %s": "User activated: %s", "User deactivated: %s": "User deactivated: %s", "User assignments updated: %s": "User assignments updated: %s", - "Tenant, role, or department assignments were changed.": "Tenant, role, or department assignments were changed." + "Tenant, role, or department assignments were changed.": "Tenant, role, or department assignments were changed.", + "Scheduled job failed: %s": "Scheduled job failed: %s", + "Error: %s": "Error: %s" } diff --git a/modules/notifications/lib/Module/Notifications/Listeners/SchedulerJobFailedNotificationListener.php b/modules/notifications/lib/Module/Notifications/Listeners/SchedulerJobFailedNotificationListener.php new file mode 100644 index 0000000..1c693e5 --- /dev/null +++ b/modules/notifications/lib/Module/Notifications/Listeners/SchedulerJobFailedNotificationListener.php @@ -0,0 +1,89 @@ +userReadRepository->listPrivilegedUserIdsByPermissionKeys( + [PermissionService::JOBS_MANAGE] + ); + if ($adminUserIds === []) { + return; + } + $adminSet = array_flip($adminUserIds); + + $titleParams = [$displayLabel]; + $bodyParams = $errorCode !== '' ? [$errorCode] : []; + + $message = NotificationMessage::localized( + 'scheduler.job_failed', + 'Scheduled job failed: %s', + $titleParams, + $errorCode !== '' ? 'Error: %s' : null, + $bodyParams, + 'admin/scheduled-jobs', + [ + 'job_id' => $jobId, + 'job_key' => $jobKey, + 'error_code' => $errorCode, + 'dedupe_target' => 'job:' . $jobKey, + ] + ); + + $tenantIds = $this->resolveAdminTenantIds($adminUserIds); + + foreach ($tenantIds as $tenantId) { + $this->notificationService->createForTenantAdminUsersFromMessage( + $tenantId, + $message, + null, + $adminSet + ); + } + } + + /** + * Collect all unique tenant IDs across admin users. + * + * @param list $userIds + * @return list + */ + private function resolveAdminTenantIds(array $userIds): array + { + $tenantIds = []; + foreach ($userIds as $userId) { + foreach ($this->userTenantRepository->listTenantIdsByUserId($userId) as $tenantId) { + $tenantIds[$tenantId] = true; + } + } + + return array_keys($tenantIds); + } +} diff --git a/modules/notifications/lib/Module/Notifications/NotificationsContainerRegistrar.php b/modules/notifications/lib/Module/Notifications/NotificationsContainerRegistrar.php index dfeebbb..5ae28cb 100644 --- a/modules/notifications/lib/Module/Notifications/NotificationsContainerRegistrar.php +++ b/modules/notifications/lib/Module/Notifications/NotificationsContainerRegistrar.php @@ -9,6 +9,7 @@ use MintyPHP\Module\Notifications\Listeners\UserActivatedNotificationListener; use MintyPHP\Module\Notifications\Listeners\UserAssignmentChangedNotificationListener; use MintyPHP\Module\Notifications\Listeners\UserCreatedNotificationListener; use MintyPHP\Module\Notifications\Listeners\UserDeactivatedNotificationListener; +use MintyPHP\Module\Notifications\Listeners\SchedulerJobFailedNotificationListener; use MintyPHP\Module\Notifications\Listeners\UserDeletedNotificationListener; use MintyPHP\Module\Notifications\Repository\NotificationRepository; use MintyPHP\Module\Notifications\Service\NotificationService; @@ -62,5 +63,11 @@ final class NotificationsContainerRegistrar implements ContainerRegistrar $c->get(NotificationService::class), $c->get(UserReadRepository::class) )); + + $container->set(SchedulerJobFailedNotificationListener::class, static fn (AppContainer $c): SchedulerJobFailedNotificationListener => new SchedulerJobFailedNotificationListener( + $c->get(NotificationService::class), + $c->get(UserReadRepository::class), + $c->get(UserTenantRepository::class) + )); } } diff --git a/modules/notifications/lib/Module/Notifications/Service/NotificationService.php b/modules/notifications/lib/Module/Notifications/Service/NotificationService.php index d59d96a..be8abc2 100644 --- a/modules/notifications/lib/Module/Notifications/Service/NotificationService.php +++ b/modules/notifications/lib/Module/Notifications/Service/NotificationService.php @@ -15,6 +15,7 @@ class NotificationService 'user.activated' => true, 'user.deactivated' => true, 'user.assignment_changed' => true, + 'scheduler.job_failed' => true, ]; public function __construct( diff --git a/modules/notifications/module.php b/modules/notifications/module.php index 5ec349c..be0c698 100644 --- a/modules/notifications/module.php +++ b/modules/notifications/module.php @@ -90,6 +90,9 @@ return [ 'user.assignment_changed' => [ ['class' => \MintyPHP\Module\Notifications\Listeners\UserAssignmentChangedNotificationListener::class, 'method' => 'handle'], ], + 'scheduler.job_failed' => [ + ['class' => \MintyPHP\Module\Notifications\Listeners\SchedulerJobFailedNotificationListener::class, 'method' => 'handle'], + ], ], 'scheduler_jobs' => [ diff --git a/modules/notifications/tests/Module/Notifications/Listeners/SchedulerJobFailedNotificationListenerTest.php b/modules/notifications/tests/Module/Notifications/Listeners/SchedulerJobFailedNotificationListenerTest.php new file mode 100644 index 0000000..55a194d --- /dev/null +++ b/modules/notifications/tests/Module/Notifications/Listeners/SchedulerJobFailedNotificationListenerTest.php @@ -0,0 +1,130 @@ +notifService = $this->createMock(NotificationService::class); + $this->userRepo = $this->createMock(UserReadRepositoryInterface::class); + $this->utRepo = $this->createMock(UserTenantRepositoryInterface::class); + $this->listener = new SchedulerJobFailedNotificationListener($this->notifService, $this->userRepo, $this->utRepo); + } + + public function testHandleCreatesNotificationsForAdminsAcrossTenants(): void + { + $this->userRepo->expects($this->any())->method('listPrivilegedUserIdsByPermissionKeys') + ->with([PermissionService::JOBS_MANAGE]) + ->willReturn([20, 30]); + + $this->utRepo->expects($this->exactly(2)) + ->method('listTenantIdsByUserId') + ->willReturnMap([ + [20, [1, 2]], + [30, [2, 3]], + ]); + + $this->notifService->expects($this->exactly(3)) + ->method('createForTenantAdminUsersFromMessage') + ->willReturnCallback(function (int $tenantId, NotificationMessage $message, ?int $excludeUserId, array $adminSet): int { + $this->assertContains($tenantId, [1, 2, 3]); + $this->assertSame('scheduler.job_failed', $message->type()); + $this->assertStringContainsString('System audit purge', $message->title()); + $this->assertSame('admin/scheduled-jobs', $message->link()); + $this->assertNull($excludeUserId); + $this->assertArrayHasKey(20, $adminSet); + $this->assertArrayHasKey(30, $adminSet); + $this->assertSame('Scheduled job failed: %s', $message->titleKey()); + $data = $message->data(); + $this->assertSame('job:system_audit_purge', $data['dedupe_target']); + $this->assertSame('job_not_registered', $data['error_code']); + return 1; + }); + + $this->listener->handle('scheduler.job_failed', [ + 'job_id' => 5, + 'job_key' => 'system_audit_purge', + 'label' => 'System audit purge', + 'error_code' => 'job_not_registered', + 'error_message' => null, + 'trigger_type' => 'scheduler', + ]); + } + + public function testHandleUsesJobKeyAsLabelWhenLabelEmpty(): void + { + $this->userRepo->expects($this->any())->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([10]); + $this->utRepo->expects($this->any())->method('listTenantIdsByUserId')->willReturn([1]); + + $this->notifService->expects($this->once()) + ->method('createForTenantAdminUsersFromMessage') + ->willReturnCallback(function (int $tenantId, NotificationMessage $message): int { + $this->assertStringContainsString('my_job_key', $message->title()); + return 1; + }); + + $this->listener->handle('scheduler.job_failed', [ + 'job_id' => 1, + 'job_key' => 'my_job_key', + 'label' => '', + 'error_code' => 'unexpected_error', + ]); + } + + public function testHandleSkipsEmptyPayload(): void + { + $this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage'); + + $this->listener->handle('scheduler.job_failed', []); + $this->listener->handle('scheduler.job_failed', ['job_key' => '', 'job_id' => 0]); + $this->listener->handle('scheduler.job_failed', ['job_key' => 'test', 'job_id' => 0]); + } + + public function testHandleSkipsWhenNoAdmins(): void + { + $this->userRepo->expects($this->any())->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([]); + $this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage'); + + $this->listener->handle('scheduler.job_failed', [ + 'job_id' => 5, + 'job_key' => 'some_job', + 'label' => 'Some Job', + 'error_code' => 'unexpected_error', + ]); + } + + public function testHandleOmitsBodyWhenNoErrorCode(): void + { + $this->userRepo->expects($this->any())->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([10]); + $this->utRepo->expects($this->any())->method('listTenantIdsByUserId')->willReturn([1]); + + $this->notifService->expects($this->once()) + ->method('createForTenantAdminUsersFromMessage') + ->willReturnCallback(function (int $tenantId, NotificationMessage $message): int { + $this->assertNull($message->body()); + return 1; + }); + + $this->listener->handle('scheduler.job_failed', [ + 'job_id' => 1, + 'job_key' => 'test_job', + 'label' => 'Test', + 'error_code' => '', + ]); + } +}