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