Files
breadcrumb-the-shire/lib/Service/Scheduler/ScheduledJobRegistry.php
fs c7b8fd516a feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.

New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering

New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
  module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
  FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:19:56 +01:00

235 lines
8.2 KiB
PHP

<?php
namespace MintyPHP\Service\Scheduler;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
use MintyPHP\Service\Scheduler\Handler\SystemAuditPurgeJobHandler;
use MintyPHP\Service\Scheduler\Handler\UserLifecycleJobHandler;
use MintyPHP\Service\User\UserLifecycleService;
use RuntimeException;
class ScheduledJobRegistry
{
public const USER_LIFECYCLE_RUN = 'user_lifecycle_run';
public const SYSTEM_AUDIT_PURGE = 'system_audit_purge';
/** @var array<string, ScheduledJobHandlerInterface> */
private array $handlers;
/**
* @var array<string, 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<string>
* }>
*/
private array $moduleJobs = [];
public function __construct(
UserLifecycleService $userLifecycleService,
SystemAuditService $systemAuditService,
private readonly AppContainer $container
) {
$this->handlers = [
self::USER_LIFECYCLE_RUN => new UserLifecycleJobHandler($userLifecycleService),
self::SYSTEM_AUDIT_PURGE => new SystemAuditPurgeJobHandler($systemAuditService),
];
$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' => array_values($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<string>
* } $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<string>
* }
*/
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;
}
}