Move the entire audit subsystem (system audit, API audit, import audit, user lifecycle audit, frontend telemetry) from core into modules/audit/. Core decoupling via interface-based injection: - AuditRecorderInterface replaces SystemAuditService in 10+ core services - UserLifecycleAuditInterface / ImportAuditInterface for specialized flows - NullAuditRecorder fallback when audit module is disabled - ApiBootstrap/ApiResponse use null-safe callable resolvers Module structure (modules/audit/): - Manifest with routes, permissions, scheduler jobs, authorization policy - 9 services, 8 repositories, 6 domain enums, 4 job handlers - 33 page files, 4 JS files, 8 test files, migration scripts, i18n Core cleanup: - OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService surgically cleaned of audit-specific constants - Sidebar template cleared of hardcoded audit navigation - AuditModuleIsolationContractTest ensures no future core→module coupling All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5 clean, architecture contracts verified. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
230 lines
7.9 KiB
PHP
230 lines
7.9 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Service\Scheduler;
|
|
|
|
use MintyPHP\App\AppContainer;
|
|
use MintyPHP\App\Module\ModuleRegistry;
|
|
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
|
|
use MintyPHP\Service\Scheduler\Handler\UserLifecycleJobHandler;
|
|
use MintyPHP\Service\User\UserLifecycleService;
|
|
use RuntimeException;
|
|
|
|
class ScheduledJobRegistry
|
|
{
|
|
public const USER_LIFECYCLE_RUN = 'user_lifecycle_run';
|
|
|
|
/** @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,
|
|
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' => 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;
|
|
}
|
|
}
|