Files
breadcrumb-the-shire/lib/Service/Search/SearchDataService.php
fs c364e2b46d feat: introduce module system and extract address book as first module (MODULAR-MONOLITH-V1-001)
Add a module kernel (ModuleManifest, ModuleRegistry) that allows modules to
contribute routes, UI slots, search providers, layout context, session
lifecycle, and permissions. Modules are activated via config/modules.php or
APP_ENABLED_MODULES env variable. Conflicts (duplicate routes, permissions,
slot keys) cause a fail-fast with a clear error message.

Extract the address book from hardcoded Core integration points into the
first module (modules/addressbook/). The module provides:
- Aside icon-bar tab + People panel via UI slot system
- Global search resource via AddressBookSearchProvider
- Layout context data via AddressBookLayoutProvider
- Session lifecycle via AddressBookSessionProvider

Core cleanup removes address-book hardcodings from SearchSqlResourceProvider,
SearchUiMetaProvider, SearchItemMapperProvider, appBuildLayoutNavContext(),
and the aside templates. Permissions (ADDRESS_BOOK_VIEW) and business logic
(AddressBookService) remain in Core as they gate general user visibility.

Includes 38 new tests (894 total), PHPStan level 5 clean, and architecture
tests verifying zero hardcoded address-book references in search/templates.

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

278 lines
10 KiB
PHP

<?php
namespace MintyPHP\Service\Search;
use MintyPHP\Repository\Search\SearchQueryRepository;
use MintyPHP\Repository\Tenant\UserTenantRepository;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Support\SearchConfig;
class SearchDataService
{
public function __construct(
private readonly PermissionService $permissionService,
private readonly UserTenantRepository $userTenantRepository,
private readonly SearchQueryRepository $searchQueryRepository
) {
}
/**
* @return array{data: array<int, array<string, mixed>>, total: int}
*/
public function buildResultData(int $userId, string $query, string $locale, int $limit, int $offset): array
{
$tenantScope = $this->resolveTenantScope($userId);
$tenantIds = $tenantScope['tenantIds'];
$tenantScoped = $tenantScope['tenantScoped'];
$tenantScopeFilters = SearchConfig::tenantScopeFilters();
$resources = SearchConfig::resources($query, $locale);
// Merge module-contributed search resources into the pipeline
$like = \MintyPHP\Support\Search\SearchQueryNormalizer::normalizeLikeQuery($query);
foreach (SearchConfig::moduleResources() as $key => $moduleDef) {
$countSql = $moduleDef['count_sql'];
$previewSql = $moduleDef['preview_sql'];
$resultSql = $moduleDef['result_sql'];
$resources[] = [
'key' => $key,
'label' => $moduleDef['label'],
'permission' => $moduleDef['permission'],
'countSql' => $countSql,
'countParams' => array_fill(0, substr_count($countSql, '?'), $like),
'previewSql' => $previewSql,
'previewParams' => array_fill(0, max(0, substr_count($previewSql, '?') - 1), $like),
'resultSql' => $resultSql,
'resultParams' => array_fill(0, substr_count($resultSql, '?'), $like),
];
}
$items = [];
foreach ($resources as $resource) {
$key = (string) ($resource['key'] ?? '');
if ($key === '') {
continue;
}
$perm = (string) ($resource['permission'] ?? '');
if ($perm !== '' && !$this->permissionService->userHas($userId, $perm)) {
continue;
}
$tenantFilter = (string) ($tenantScopeFilters[$key] ?? '');
$isTenantScoped = $tenantFilter !== '';
if ($isTenantScoped && !$tenantScoped) {
continue;
}
$sql = (string) ($resource['resultSql'] ?? '');
if ($sql === '') {
continue;
}
$sql = str_replace('{{tenantFilter}}', $isTenantScoped ? $tenantFilter : '', $sql);
$params = is_array($resource['resultParams'] ?? null) ? $resource['resultParams'] : [];
if ($isTenantScoped && strpos($sql, '???') !== false) {
$params[] = $tenantIds;
}
$rows = $this->searchQueryRepository->selectRows($sql, $params);
foreach ($rows as $row) {
$data = self::normalizeResourceRow($row);
$mapped = SearchConfig::mapResultItem($key, (string) ($resource['label'] ?? ''), $data);
if ($mapped !== null) {
$items[] = $mapped;
}
}
}
$items = array_merge($items, SearchConfig::hotkeyResultItems($query));
if ($this->permissionService->userHas($userId, PermissionService::DOCS_VIEW)) {
$items = array_merge($items, SearchConfig::docsResultItems($query));
}
$scoreQuery = SearchConfig::normalizeScoreQuery($query);
$queryLower = mb_strtolower($scoreQuery);
$scoreItem = static function (array $item) use ($queryLower): int {
$manualScore = (int) ($item['search_score'] ?? 0);
if ($manualScore > 0) {
return $manualScore;
}
$title = mb_strtolower((string) ($item['title'] ?? ''));
$description = mb_strtolower((string) ($item['description'] ?? ''));
if ($title === $queryLower) {
return 400;
}
if ($title !== '' && strpos($title, $queryLower) === 0) {
return 300;
}
if ($title !== '' && strpos($title, $queryLower) !== false) {
return 200;
}
if ($description !== '' && strpos($description, $queryLower) !== false) {
return 100;
}
return 0;
};
usort($items, static function (array $a, array $b) use ($scoreItem): int {
$scoreA = $scoreItem($a);
$scoreB = $scoreItem($b);
if ($scoreA !== $scoreB) {
return $scoreB <=> $scoreA;
}
$title = strcmp((string) ($a['title'] ?? ''), (string) ($b['title'] ?? ''));
if ($title !== 0) {
return $title;
}
return strcmp((string) ($a['type'] ?? ''), (string) ($b['type'] ?? ''));
});
$total = count($items);
$items = array_slice($items, $offset, $limit);
return [
'data' => $items,
'total' => $total,
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function buildPreviewData(int $userId, string $query, string $locale, int $previewLimit = 5): array
{
$tenantScope = $this->resolveTenantScope($userId);
$tenantIds = $tenantScope['tenantIds'];
$tenantScoped = $tenantScope['tenantScoped'];
$tenantScopeFilters = SearchConfig::tenantScopeFilters();
$resources = SearchConfig::resources($query, $locale);
// Merge module-contributed search resources
$like = \MintyPHP\Support\Search\SearchQueryNormalizer::normalizeLikeQuery($query);
foreach (SearchConfig::moduleResources() as $key => $moduleDef) {
$countSql = $moduleDef['count_sql'];
$previewSql = $moduleDef['preview_sql'];
$resultSql = $moduleDef['result_sql'];
$resources[] = [
'key' => $key,
'label' => $moduleDef['label'],
'permission' => $moduleDef['permission'],
'countSql' => $countSql,
'countParams' => array_fill(0, substr_count($countSql, '?'), $like),
'previewSql' => $previewSql,
'previewParams' => array_fill(0, max(0, substr_count($previewSql, '?') - 1), $like),
'resultSql' => $resultSql,
'resultParams' => array_fill(0, substr_count($resultSql, '?'), $like),
];
}
$results = [];
foreach ($resources as $resource) {
$key = (string) ($resource['key'] ?? '');
if ($key === '') {
continue;
}
$tenantFilter = (string) ($tenantScopeFilters[$key] ?? '');
$isTenantScoped = $tenantFilter !== '';
if ($isTenantScoped && !$tenantScoped) {
continue;
}
$perm = (string) ($resource['permission'] ?? '');
if ($perm !== '' && !$this->permissionService->userHas($userId, $perm)) {
continue;
}
$countSql = str_replace('{{tenantFilter}}', $isTenantScoped ? $tenantFilter : '', (string) ($resource['countSql'] ?? ''));
$countParams = is_array($resource['countParams'] ?? null) ? $resource['countParams'] : [];
if ($isTenantScoped && strpos($countSql, '???') !== false) {
$countParams[] = $tenantIds;
}
$count = $this->searchQueryRepository->selectCount($countSql, $countParams);
$items = [];
$previewSql = $resource['previewSql'] ?? null;
if (is_string($previewSql) && $previewSql !== '') {
$previewSql = str_replace('{{tenantFilter}}', $isTenantScoped ? $tenantFilter : '', $previewSql);
$previewParams = is_array($resource['previewParams'] ?? null) ? $resource['previewParams'] : [];
if ($isTenantScoped && strpos($previewSql, '???') !== false) {
$previewParams[] = $tenantIds;
}
$previewParams[] = $previewLimit;
$rows = $this->searchQueryRepository->selectRows($previewSql, $previewParams);
foreach ($rows as $row) {
$data = self::normalizeResourceRow($row);
$mapped = SearchConfig::mapPreviewItem($key, $data, $query);
if ($mapped !== null) {
$items[] = $mapped;
}
}
}
$result = [
'key' => $key,
'label' => $resource['label'] ?? '',
'count' => $count,
'url' => SearchConfig::listUrl($key, $query),
'items' => $items,
];
if ($key === 'pages' && !empty($items[0]['url'])) {
$result['url'] = $items[0]['url'];
}
$results[] = $result;
}
$hotkeyResource = SearchConfig::hotkeyPreviewResource($query);
if ($hotkeyResource !== null) {
$results[] = $hotkeyResource;
}
if ($this->permissionService->userHas($userId, PermissionService::DOCS_VIEW)) {
$docsResource = SearchConfig::docsPreviewResource($query);
if ($docsResource !== null) {
$results[] = $docsResource;
}
}
return $results;
}
/**
* @return array{tenantIds: array<int, int>, tenantScoped: bool}
*/
private function resolveTenantScope(int $userId): array
{
if ($userId > 0) {
// Prime cached permissions for the current request.
$this->permissionService->getUserPermissions($userId);
}
$tenantIds = $userId > 0
? $this->userTenantRepository->listTenantIdsByUserId($userId)
: [];
return [
'tenantIds' => $tenantIds,
'tenantScoped' => !empty($tenantIds),
];
}
/**
* @return array<string, mixed>
*/
private static function normalizeResourceRow(mixed $row): array
{
$data = $row;
if (is_array($row) && count($row) === 1) {
$data = reset($row);
}
return is_array($data) ? $data : [];
}
}