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:
2026-03-18 22:19:56 +01:00
parent c364e2b46d
commit c7b8fd516a
139 changed files with 7591 additions and 2535 deletions

View File

@@ -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;
}
}

View File

@@ -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),

View File

@@ -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);
}
/**

View File

@@ -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,
];
/**

View File

@@ -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();
}
}

View File

@@ -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
);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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
);
}

View File

@@ -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'],