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:
@@ -66,10 +66,15 @@ class AccessPolicyFactory
|
||||
}
|
||||
|
||||
// Assembles all policies into a single AuthorizationService.
|
||||
// Policy order doesn't matter — each ability maps to exactly one policy via supports().
|
||||
// Core policies are registered first; module-contributed policies are appended.
|
||||
// Each ability maps to exactly one policy via supports() — first match wins.
|
||||
public function createAuthorizationService(): AuthorizationService
|
||||
{
|
||||
return $this->authorizationService ??= new AuthorizationService([
|
||||
if ($this->authorizationService !== null) {
|
||||
return $this->authorizationService;
|
||||
}
|
||||
|
||||
$policies = [
|
||||
$this->createUserAuthorizationPolicy(),
|
||||
$this->createRoleAuthorizationPolicy(),
|
||||
$this->createPermissionAuthorizationPolicy(),
|
||||
@@ -77,6 +82,50 @@ class AccessPolicyFactory
|
||||
$this->createSettingsAuthorizationPolicy(),
|
||||
$this->createTenantAuthorizationPolicy(),
|
||||
$this->createDepartmentAuthorizationPolicy(),
|
||||
]);
|
||||
];
|
||||
|
||||
// Append module-contributed authorization policies.
|
||||
try {
|
||||
/** @var \MintyPHP\App\Module\ModuleRegistry $registry */
|
||||
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
|
||||
foreach ($registry->getAuthorizationPolicies() as $policyClass) {
|
||||
if (!is_string($policyClass) || trim($policyClass) === '') {
|
||||
continue;
|
||||
}
|
||||
$policy = $this->instantiateModulePolicy($policyClass);
|
||||
if ($policy instanceof AuthorizationPolicyInterface) {
|
||||
$policies[] = $policy;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open: no module registry available in this context
|
||||
}
|
||||
|
||||
return $this->authorizationService = new AuthorizationService($policies);
|
||||
}
|
||||
|
||||
private function instantiateModulePolicy(string $policyClass): ?AuthorizationPolicyInterface
|
||||
{
|
||||
if (!class_exists($policyClass)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$reflection = new \ReflectionClass($policyClass);
|
||||
$constructor = $reflection->getConstructor();
|
||||
if ($constructor === null || $constructor->getNumberOfRequiredParameters() === 0) {
|
||||
$instance = $reflection->newInstance();
|
||||
return $instance instanceof AuthorizationPolicyInterface ? $instance : null;
|
||||
}
|
||||
|
||||
if ($constructor->getNumberOfRequiredParameters() === 1) {
|
||||
$instance = $reflection->newInstance($this->permissionService);
|
||||
return $instance instanceof AuthorizationPolicyInterface ? $instance : null;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace MintyPHP\Service\Access;
|
||||
|
||||
final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
{
|
||||
public const ABILITY_ADDRESS_BOOK_VIEW = 'ops.address_book.view';
|
||||
public const ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW = 'ops.admin.user_lifecycle_audit.view';
|
||||
public const ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE = 'ops.admin.users.lifecycle_restore';
|
||||
public const ABILITY_ADMIN_API_AUDIT_VIEW = 'ops.admin.api_audit.view';
|
||||
@@ -49,7 +48,6 @@ final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterfac
|
||||
public function supports(string $ability): bool
|
||||
{
|
||||
return in_array($ability, [
|
||||
self::ABILITY_ADDRESS_BOOK_VIEW,
|
||||
self::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW,
|
||||
self::ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE,
|
||||
self::ABILITY_ADMIN_API_AUDIT_VIEW,
|
||||
@@ -98,7 +96,6 @@ final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterfac
|
||||
return match ($ability) {
|
||||
self::ABILITY_ADMIN_USERS_CREATE_EDIT_CUSTOM_FIELDS => $this->authorizeUsersCreateCustomFields($actorUserId),
|
||||
self::ABILITY_API_TOKENS_SELF_MANAGE => $this->authorizeApiTokensSelfManage($actorUserId),
|
||||
self::ABILITY_ADDRESS_BOOK_VIEW => $this->allowIfHas($actorUserId, PermissionService::ADDRESS_BOOK_VIEW),
|
||||
self::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::USER_LIFECYCLE_AUDIT_VIEW),
|
||||
self::ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE => $this->allowIfHas($actorUserId, PermissionService::USERS_LIFECYCLE_RESTORE),
|
||||
self::ABILITY_ADMIN_API_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::API_AUDIT_VIEW),
|
||||
|
||||
@@ -80,11 +80,28 @@ final class UiAccessService
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve layout capabilities from Core + module-contributed capabilities.
|
||||
*
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
public function layoutCapabilities(int $actorUserId): array
|
||||
{
|
||||
return $this->resolveMap($actorUserId, UiCapabilityMap::LAYOUT);
|
||||
$map = UiCapabilityMap::LAYOUT;
|
||||
|
||||
// Merge module-contributed layout capabilities dynamically.
|
||||
try {
|
||||
/** @var \MintyPHP\App\Module\ModuleRegistry $registry */
|
||||
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
|
||||
if ($registry instanceof \MintyPHP\App\Module\ModuleRegistry) {
|
||||
foreach ($registry->getLayoutCapabilities() as $key => $ability) {
|
||||
$map[$key] = $ability;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open: no module registry available in this context
|
||||
}
|
||||
|
||||
return $this->resolveMap($actorUserId, $map);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,11 @@ namespace MintyPHP\Service\Access;
|
||||
final class UiCapabilityMap
|
||||
{
|
||||
/**
|
||||
* Core layout capabilities — admin panel visibility checks.
|
||||
*
|
||||
* Module-contributed capabilities are registered via the module manifest's
|
||||
* 'layout_capabilities' field and merged dynamically in UiAccessService.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public const LAYOUT = [
|
||||
@@ -24,7 +29,6 @@ final class UiCapabilityMap
|
||||
'can_view_imports_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_IMPORTS_AUDIT_VIEW,
|
||||
'can_view_user_lifecycle_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW,
|
||||
'can_view_stats' => OperationsAuthorizationPolicy::ABILITY_ADMIN_STATS_VIEW,
|
||||
'can_view_address_book' => OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -82,7 +82,7 @@ class AuthService
|
||||
|
||||
$sessionUser = $this->sessionUser();
|
||||
if (!($sessionUser['active'] ?? 1)) {
|
||||
Auth::logout();
|
||||
$this->logoutWithModuleCleanup();
|
||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||
'error_code' => 'account_inactive',
|
||||
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
|
||||
@@ -96,7 +96,7 @@ class AuthService
|
||||
|
||||
$userId = (int) ($sessionUser['id'] ?? 0);
|
||||
if ($userId > 0 && !$this->isLocalPasswordLoginAllowedForUser($userId)) {
|
||||
Auth::logout();
|
||||
$this->logoutWithModuleCleanup();
|
||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||
'error_code' => 'password_login_disabled',
|
||||
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
|
||||
@@ -111,7 +111,7 @@ class AuthService
|
||||
$this->permissionService->getUserPermissions($userId, true);
|
||||
$this->loadTenantDataIntoSession($userId);
|
||||
if ($this->noActiveTenant()) {
|
||||
Auth::logout();
|
||||
$this->logoutWithModuleCleanup();
|
||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||
'error_code' => 'no_active_tenant',
|
||||
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
|
||||
@@ -203,7 +203,7 @@ class AuthService
|
||||
$this->permissionService->getUserPermissions($userId, true);
|
||||
$this->loadTenantDataIntoSession($userId);
|
||||
if ($this->noActiveTenant()) {
|
||||
Auth::logout();
|
||||
$this->logoutWithModuleCleanup();
|
||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||
'error_code' => 'login_no_active_tenant',
|
||||
'actor_user_id' => $userId,
|
||||
@@ -277,7 +277,7 @@ class AuthService
|
||||
$actorUserId = (int) ($this->sessionUser()['id'] ?? 0);
|
||||
$actorTenantId = $this->currentTenantIdFromSession();
|
||||
$this->rememberMeService->forgetCurrentUser();
|
||||
Auth::logout();
|
||||
$this->logoutWithModuleCleanup();
|
||||
$this->recordAuthEvent('auth.logout', 'success', [
|
||||
'actor_user_id' => $actorUserId > 0 ? $actorUserId : null,
|
||||
'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null,
|
||||
@@ -290,7 +290,7 @@ class AuthService
|
||||
$actorTenantId = $this->currentTenantIdFromSession();
|
||||
$this->sessionStore->remove('session_started_at');
|
||||
$this->sessionStore->remove('session_last_activity');
|
||||
Auth::logout();
|
||||
$this->logoutWithModuleCleanup();
|
||||
$this->recordAuthEvent('auth.session.timeout', 'success', [
|
||||
'actor_user_id' => $actorUserId > 0 ? $actorUserId : null,
|
||||
'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null,
|
||||
@@ -413,4 +413,10 @@ class AuthService
|
||||
{
|
||||
return (bool) $this->sessionStore->get('no_active_tenant', false);
|
||||
}
|
||||
|
||||
private function logoutWithModuleCleanup(): void
|
||||
{
|
||||
$this->authSessionTenantContextService->clearModuleSessionData();
|
||||
Auth::logout();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\Http\CookieStoreInterface;
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Service\Audit\AuditServicesFactory;
|
||||
use MintyPHP\Service\Bookmark\BookmarkServicesFactory;
|
||||
use MintyPHP\Service\Mail\MailServicesFactory;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
|
||||
@@ -34,7 +34,7 @@ class AuthServicesFactory
|
||||
private readonly SessionStoreInterface $sessionStore,
|
||||
private readonly CookieStoreInterface $cookieStore,
|
||||
private readonly RequestRuntimeInterface $requestRuntime,
|
||||
private readonly BookmarkServicesFactory $bookmarkServicesFactory
|
||||
private readonly AppContainer $appContainer
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -152,8 +152,8 @@ class AuthServicesFactory
|
||||
{
|
||||
return $this->authSessionTenantContextService ??= new AuthSessionTenantContextService(
|
||||
$this->userServicesFactory->createUserTenantContextService(),
|
||||
$this->bookmarkServicesFactory->createBookmarkService(),
|
||||
$this->sessionStore
|
||||
$this->sessionStore,
|
||||
$this->appContainer
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\Contracts\SessionProvider;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
||||
use MintyPHP\Service\User\UserTenantContextService;
|
||||
|
||||
class AuthSessionTenantContextService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserTenantContextService $userTenantContextService,
|
||||
private readonly BookmarkService $bookmarkService,
|
||||
private readonly SessionStoreInterface $sessionStore
|
||||
private readonly SessionStoreInterface $sessionStore,
|
||||
private readonly AppContainer $container
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -23,8 +25,6 @@ class AuthSessionTenantContextService
|
||||
|
||||
$availableTenants = $this->userTenantContextService->getAvailableTenants($userId);
|
||||
$this->sessionStore->set('available_tenants', $availableTenants);
|
||||
$this->sessionStore->set('available_departments_by_tenant', $this->userTenantContextService->getAvailableDepartmentsByTenant($userId));
|
||||
$this->sessionStore->set('user_bookmarks', $this->bookmarkService->listGroupedForUser($userId));
|
||||
|
||||
if (!$availableTenants) {
|
||||
$this->sessionStore->set('no_active_tenant', true);
|
||||
@@ -43,5 +43,45 @@ class AuthSessionTenantContextService
|
||||
if ($currentTenant) {
|
||||
$this->sessionStore->set('current_tenant', $currentTenant);
|
||||
}
|
||||
|
||||
$user = $this->sessionStore->get('user', []);
|
||||
$userRecord = is_array($user) ? $user : [];
|
||||
foreach ($this->moduleSessionProviders() as $provider) {
|
||||
$provider->populate($userRecord, $this->container);
|
||||
}
|
||||
}
|
||||
|
||||
public function clearModuleSessionData(): void
|
||||
{
|
||||
foreach ($this->moduleSessionProviders() as $provider) {
|
||||
$provider->clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<SessionProvider>
|
||||
*/
|
||||
private function moduleSessionProviders(): array
|
||||
{
|
||||
$providers = [];
|
||||
try {
|
||||
$registry = $this->container->get(ModuleRegistry::class);
|
||||
if (!$registry instanceof ModuleRegistry) {
|
||||
return [];
|
||||
}
|
||||
foreach ($registry->getSessionProviders() as $providerClass) {
|
||||
if (!class_exists($providerClass)) {
|
||||
continue;
|
||||
}
|
||||
$provider = new $providerClass();
|
||||
if ($provider instanceof SessionProvider) {
|
||||
$providers[] = $provider;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $providers;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ class RememberMeService
|
||||
if ((bool) $this->sessionStore->get('no_active_tenant', false)) {
|
||||
// Enforce the same tenant policy as interactive login.
|
||||
$this->forgetCurrentUser();
|
||||
$this->authSessionTenantContextService->clearModuleSessionData();
|
||||
Auth::logout();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -410,7 +410,16 @@ class UserCustomFieldValueService
|
||||
return ['prepared' => $prepared, 'errors' => $errors];
|
||||
}
|
||||
|
||||
public function extractAddressBookFilterSpec(array $query, int $currentUserId): array
|
||||
/**
|
||||
* Extract custom field filter specification from a URL query.
|
||||
*
|
||||
* Parses cf_*, cfm_*, and cfd_* query parameters against the user's
|
||||
* visible custom field definitions and returns validated filter data.
|
||||
*
|
||||
* @param array<string, mixed> $query URL query parameters
|
||||
* @return array{definitions: list<array<string, mixed>>, filters: array<string, array<int, mixed>>, query: array<string, string>}
|
||||
*/
|
||||
public function extractCustomFieldFilterSpec(array $query, int $currentUserId): array
|
||||
{
|
||||
$tenantIds = $this->userScopeGateway()->getUserTenantIds($currentUserId);
|
||||
$definitions = TenantCustomFieldDefinitionRepository::listFilterableByTenantIds($tenantIds);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace MintyPHP\Service\Scheduler;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\Repository\Scheduler\ScheduledJobRepositoryInterface;
|
||||
use MintyPHP\Repository\Scheduler\ScheduledJobRunRepositoryInterface;
|
||||
use MintyPHP\Repository\Scheduler\SchedulerRuntimeRepositoryInterface;
|
||||
@@ -22,7 +23,8 @@ class SchedulerServicesFactory
|
||||
private readonly UserServicesFactory $userServicesFactory,
|
||||
private readonly AuditServicesFactory $auditServicesFactory,
|
||||
private readonly DatabaseSessionRepository $databaseSessionRepository,
|
||||
private readonly SchedulerRepositoryFactory $schedulerRepositoryFactory
|
||||
private readonly SchedulerRepositoryFactory $schedulerRepositoryFactory,
|
||||
private readonly AppContainer $appContainer
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -84,7 +86,8 @@ class SchedulerServicesFactory
|
||||
{
|
||||
return $this->scheduledJobRegistry ??= new ScheduledJobRegistry(
|
||||
$this->getUserLifecycleService(),
|
||||
$this->getSystemAuditService()
|
||||
$this->getSystemAuditService(),
|
||||
$this->appContainer
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,12 +28,14 @@ class SearchDataService
|
||||
$tenantScopeFilters = SearchConfig::tenantScopeFilters();
|
||||
$resources = SearchConfig::resources($query, $locale);
|
||||
|
||||
// Merge module-contributed search resources into the pipeline
|
||||
// Merge module-contributed search resources into the pipeline.
|
||||
// Replace {{userId}} before counting ? placeholders so it is not treated as a bind param.
|
||||
$like = \MintyPHP\Support\Search\SearchQueryNormalizer::normalizeLikeQuery($query);
|
||||
$userIdStr = (string) $userId;
|
||||
foreach (SearchConfig::moduleResources() as $key => $moduleDef) {
|
||||
$countSql = $moduleDef['count_sql'];
|
||||
$previewSql = $moduleDef['preview_sql'];
|
||||
$resultSql = $moduleDef['result_sql'];
|
||||
$countSql = str_replace('{{userId}}', $userIdStr, $moduleDef['count_sql']);
|
||||
$previewSql = str_replace('{{userId}}', $userIdStr, $moduleDef['preview_sql']);
|
||||
$resultSql = str_replace('{{userId}}', $userIdStr, $moduleDef['result_sql']);
|
||||
$resources[] = [
|
||||
'key' => $key,
|
||||
'label' => $moduleDef['label'],
|
||||
@@ -150,12 +152,14 @@ class SearchDataService
|
||||
$tenantScopeFilters = SearchConfig::tenantScopeFilters();
|
||||
$resources = SearchConfig::resources($query, $locale);
|
||||
|
||||
// Merge module-contributed search resources
|
||||
// Merge module-contributed search resources.
|
||||
// Replace {{userId}} before counting ? placeholders so it is not treated as a bind param.
|
||||
$like = \MintyPHP\Support\Search\SearchQueryNormalizer::normalizeLikeQuery($query);
|
||||
$userIdStr = (string) $userId;
|
||||
foreach (SearchConfig::moduleResources() as $key => $moduleDef) {
|
||||
$countSql = $moduleDef['count_sql'];
|
||||
$previewSql = $moduleDef['preview_sql'];
|
||||
$resultSql = $moduleDef['result_sql'];
|
||||
$countSql = str_replace('{{userId}}', $userIdStr, $moduleDef['count_sql']);
|
||||
$previewSql = str_replace('{{userId}}', $userIdStr, $moduleDef['preview_sql']);
|
||||
$resultSql = str_replace('{{userId}}', $userIdStr, $moduleDef['result_sql']);
|
||||
$resources[] = [
|
||||
'key' => $key,
|
||||
'label' => $moduleDef['label'],
|
||||
|
||||
Reference in New Issue
Block a user