feat(scheduler): dispatch event on job failure, notify admins via notifications module

Add ?ModuleEventDispatcher to SchedulerRunService (fail-open, nullable).
Dispatch scheduler.job_failed event when a job fails with payload:
job_id, job_key, label, error_code, error_message, trigger_type.

New SchedulerJobFailedNotificationListener in notifications module
creates in-app admin notifications for users with JOBS_MANAGE
permission, scoped per tenant, with 30-min dedup per job_key.

Also fixes audit module manifest: system_audit_purge job_key was
mismatched (audit_system_purge vs system_audit_purge in DB).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 12:26:54 +02:00
parent 576a0c51cd
commit 97ff3ce135
10 changed files with 276 additions and 5 deletions

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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',

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -0,0 +1,89 @@
<?php
namespace MintyPHP\Module\Notifications\Listeners;
use MintyPHP\App\Module\Contracts\EventListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Service\Access\PermissionService;
final class SchedulerJobFailedNotificationListener implements EventListener
{
public function __construct(
private readonly NotificationService $notificationService,
private readonly UserReadRepositoryInterface $userReadRepository,
private readonly UserTenantRepositoryInterface $userTenantRepository
) {
}
public function handle(string $event, array $payload): void
{
$jobKey = trim((string) ($payload['job_key'] ?? ''));
$jobId = (int) ($payload['job_id'] ?? 0);
$label = trim((string) ($payload['label'] ?? ''));
$errorCode = trim((string) ($payload['error_code'] ?? ''));
if ($jobKey === '' || $jobId <= 0) {
return;
}
$displayLabel = $label !== '' ? $label : $jobKey;
$adminUserIds = $this->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<int> $userIds
* @return list<int>
*/
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);
}
}

View File

@@ -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)
));
}
}

View File

@@ -15,6 +15,7 @@ class NotificationService
'user.activated' => true,
'user.deactivated' => true,
'user.assignment_changed' => true,
'scheduler.job_failed' => true,
];
public function __construct(

View File

@@ -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' => [

View File

@@ -0,0 +1,130 @@
<?php
namespace MintyPHP\Tests\Module\Notifications\Listeners;
use MintyPHP\Module\Notifications\Listeners\SchedulerJobFailedNotificationListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Service\Access\PermissionService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class SchedulerJobFailedNotificationListenerTest extends TestCase
{
private NotificationService&MockObject $notifService;
private UserReadRepositoryInterface&MockObject $userRepo;
private UserTenantRepositoryInterface&MockObject $utRepo;
private SchedulerJobFailedNotificationListener $listener;
protected function setUp(): void
{
$this->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' => '',
]);
}
}