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>
This commit is contained in:
@@ -2,39 +2,51 @@
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* Maps job_key => handler instance (must implement ScheduledJobHandlerInterface).
|
||||
*
|
||||
* To register a new job:
|
||||
* 1. Create lib/Service/Scheduler/Handler/YourJobHandler.php implementing the interface.
|
||||
* 2. Add a public const for the job key above.
|
||||
* 3. Add one line in __construct(): self::YOUR_JOB_KEY => new YourJobHandler(...),
|
||||
*
|
||||
* No other file needs to be changed.
|
||||
*
|
||||
* @var array<string, ScheduledJobHandlerInterface>
|
||||
*/
|
||||
/** @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
|
||||
SystemAuditService $systemAuditService,
|
||||
private readonly AppContainer $container
|
||||
) {
|
||||
$this->handlers = [
|
||||
self::USER_LIFECYCLE_RUN => new UserLifecycleJobHandler($userLifecycleService),
|
||||
self::SYSTEM_AUDIT_PURGE => new SystemAuditPurgeJobHandler($systemAuditService),
|
||||
];
|
||||
|
||||
$this->loadModuleJobs();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +59,9 @@ class ScheduledJobRegistry
|
||||
foreach ($this->handlers as $jobKey => $handler) {
|
||||
$result[$jobKey] = $handler->definition();
|
||||
}
|
||||
foreach ($this->moduleJobs as $jobKey => $definition) {
|
||||
$result[$jobKey] = $this->stripHandlerFromDefinition($definition);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -59,7 +74,13 @@ class ScheduledJobRegistry
|
||||
if ($jobKey === '') {
|
||||
return null;
|
||||
}
|
||||
return isset($this->handlers[$jobKey]) ? $this->handlers[$jobKey]->definition() : null;
|
||||
if (isset($this->handlers[$jobKey])) {
|
||||
return $this->handlers[$jobKey]->definition();
|
||||
}
|
||||
if (isset($this->moduleJobs[$jobKey])) {
|
||||
return $this->stripHandlerFromDefinition($this->moduleJobs[$jobKey]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +94,12 @@ class ScheduledJobRegistry
|
||||
*/
|
||||
public function execute(string $jobKey, ?int $actorUserId): array
|
||||
{
|
||||
if (!isset($this->handlers[$jobKey])) {
|
||||
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',
|
||||
@@ -81,6 +107,128 @@ class ScheduledJobRegistry
|
||||
'result' => [],
|
||||
];
|
||||
}
|
||||
return $this->handlers[$jobKey]->execute($actorUserId);
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user