>, 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); $items = []; foreach ($resources as $resource) { $key = (string) ($resource['key'] ?? ''); if ($key === '') { continue; } $perm = (string) ($resource['permission'] ?? ''); if ($perm !== '' && !$this->permissionGateway->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->permissionGateway->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> */ 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); $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->permissionGateway->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->permissionGateway->userHas($userId, PermissionService::DOCS_VIEW)) { $docsResource = SearchConfig::docsPreviewResource($query); if ($docsResource !== null) { $results[] = $docsResource; } } return $results; } /** * @return array{tenantIds: array, tenantScoped: bool} */ private function resolveTenantScope(int $userId): array { if ($userId > 0) { // Prime cached permissions for the current request. $this->permissionGateway->getUserPermissions($userId); } $tenantIds = $userId > 0 ? $this->userTenantRepository->listTenantIdsByUserId($userId) : []; return [ 'tenantIds' => $tenantIds, 'tenantScoped' => !empty($tenantIds), ]; } /** * @return array */ private static function normalizeResourceRow(mixed $row): array { $data = $row; if (is_array($row) && count($row) === 1) { $data = reset($row); } return is_array($data) ? $data : []; } }