*/ private array $handlers; /** * @var array * }> */ private array $moduleJobs = []; public function __construct( UserLifecycleService $userLifecycleService, private readonly AppContainer $container ) { $this->handlers = [ self::USER_LIFECYCLE_RUN => new UserLifecycleJobHandler($userLifecycleService), ]; $this->loadModuleJobs(); } /** * Returns all job definitions keyed by job_key. * Consumed by ScheduledJobService->ensureSystemJobs() to sync registry with the database. */ public function definitions(): array { $result = []; foreach ($this->handlers as $jobKey => $handler) { $result[$jobKey] = $handler->definition(); } foreach ($this->moduleJobs as $jobKey => $definition) { $result[$jobKey] = $this->stripHandlerFromDefinition($definition); } return $result; } /** * Returns the definition for a single job key, or null if not registered. */ public function get(string $jobKey): ?array { $jobKey = trim($jobKey); if ($jobKey === '') { return null; } if (isset($this->handlers[$jobKey])) { return $this->handlers[$jobKey]->definition(); } if (isset($this->moduleJobs[$jobKey])) { return $this->stripHandlerFromDefinition($this->moduleJobs[$jobKey]); } return null; } /** * Dispatches execution to the registered handler for the given job key and returns * a normalized result envelope. Unknown job keys return status 'failed' with * error_code 'job_not_supported' rather than throwing an exception. * * @param string $jobKey Registered job identifier (see class constants). * @param int|null $actorUserId Null for scheduler-triggered runs; user ID for manual runs. * @return array{status:string,error_code:?string,error_message:?string,result:array} */ public function execute(string $jobKey, ?int $actorUserId): array { if (isset($this->handlers[$jobKey])) { return $this->handlers[$jobKey]->execute($actorUserId); } $moduleDefinition = $this->moduleJobs[$jobKey] ?? null; if (!is_array($moduleDefinition)) { return [ 'status' => 'failed', 'error_code' => 'job_not_supported', 'error_message' => null, 'result' => [], ]; } $handlerClass = trim((string) $moduleDefinition['handler']); if ($handlerClass === '') { return [ 'status' => 'failed', 'error_code' => 'job_handler_missing', 'error_message' => 'Scheduler job handler is not configured.', 'result' => [], ]; } try { $handler = $this->resolveHandler($handlerClass); return $handler->execute($actorUserId); } catch (\Throwable $exception) { return [ 'status' => 'failed', 'error_code' => 'job_handler_resolution_failed', 'error_message' => $exception->getMessage(), 'result' => [], ]; } } private function loadModuleJobs(): void { $registry = $this->resolveModuleRegistry(); if (!$registry instanceof ModuleRegistry) { return; } foreach ($registry->getSchedulerJobs() as $jobDefinition) { $jobKey = trim((string) $jobDefinition['job_key']); if ($jobKey === '') { continue; } if (isset($this->handlers[$jobKey])) { throw new RuntimeException( "Module scheduler job '{$jobKey}' collides with core scheduler job key." ); } if (isset($this->moduleJobs[$jobKey])) { throw new RuntimeException( "Duplicate module scheduler job key '{$jobKey}'." ); } $this->moduleJobs[$jobKey] = [ 'handler' => trim((string) $jobDefinition['handler']), 'label' => trim((string) $jobDefinition['label']), 'description' => trim((string) $jobDefinition['description']), 'default_enabled' => (int) $jobDefinition['default_enabled'], 'default_timezone' => trim((string) $jobDefinition['default_timezone']), 'default_schedule_type' => trim((string) $jobDefinition['default_schedule_type']), 'default_schedule_interval' => (int) $jobDefinition['default_schedule_interval'], 'default_schedule_time' => $jobDefinition['default_schedule_time'], 'default_schedule_weekdays_csv' => $jobDefinition['default_schedule_weekdays_csv'], 'default_catchup_once' => (int) $jobDefinition['default_catchup_once'], 'allowed_schedule_types' => $jobDefinition['allowed_schedule_types'], ]; } } /** * @param array{ * handler: string, * label: string, * description: string, * default_enabled: int, * default_timezone: string, * default_schedule_type: string, * default_schedule_interval: int, * default_schedule_time: ?string, * default_schedule_weekdays_csv: ?string, * default_catchup_once: int, * allowed_schedule_types: list * } $definition * @return array{ * label: string, * description: string, * default_enabled: int, * default_timezone: string, * default_schedule_type: string, * default_schedule_interval: int, * default_schedule_time: ?string, * default_schedule_weekdays_csv: ?string, * default_catchup_once: int, * allowed_schedule_types: list * } */ private function stripHandlerFromDefinition(array $definition): array { unset($definition['handler']); return $definition; } private function resolveModuleRegistry(): ?ModuleRegistry { try { $registry = $this->container->get(ModuleRegistry::class); } catch (\Throwable) { return null; } return $registry instanceof ModuleRegistry ? $registry : null; } private function resolveHandler(string $handlerClass): ScheduledJobHandlerInterface { $handler = null; if ($this->container->has($handlerClass)) { $handler = $this->container->get($handlerClass); } elseif (class_exists($handlerClass)) { $handler = new $handlerClass(); } if (!$handler instanceof ScheduledJobHandlerInterface) { throw new RuntimeException( "Scheduler job handler '{$handlerClass}' must implement " . ScheduledJobHandlerInterface::class . '.' ); } return $handler; } }