Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.64% covered (warning)
87.64%
156 / 178
25.00% covered (danger)
25.00%
4 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
GlobalSearchService
87.57% covered (warning)
87.57%
155 / 177
25.00% covered (danger)
25.00%
4 / 16
108.25
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 search
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
7
 getSearchSettings
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 buildFallbackSearchSettings
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 parseRawSettings
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
5.93
 saveSearchSettings
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 getSearchableModules
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 resolveTargetModules
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
8.06
 isModuleEligibleForSearch
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
10.16
 resolveAllowedMenuRoutes
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
 extractMenuRoutesRecursively
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
 resolveModuleLabel
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
10.00
 canUserAccessModule
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
21.52
 resolveSearchableFields
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 filterPermittedFields
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
9.06
 resolveConfiguredLimit
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Search\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Access\Domain\Repository\AccessRepositoryInterface;
12use App\Core\Engine\Domain\Model\FieldMetadata;
13use App\Core\Engine\Domain\Model\ModuleMetadata;
14use App\Core\Engine\Domain\Model\PermissionContext;
15use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
16use App\Core\Instance\Application\Service\InstanceContextManagerInterface;
17use App\Core\Preference\Domain\Model\PreferenceScope;
18use App\Core\Preference\Domain\Model\UserPreference;
19use App\Core\Preference\Domain\Repository\UserPreferenceRepositoryInterface;
20use App\Core\Search\Domain\Model\SearchQuery;
21use App\Core\Search\Domain\Model\SearchResultGroup;
22use App\Core\Search\Domain\Model\SearchResultSet;
23use App\Core\Search\Domain\Model\SearchSettings;
24use App\Core\Search\Domain\Repository\SearchRepositoryInterface;
25use App\Modules\Menu\Domain\Model\MenuItem;
26use App\Modules\Menu\Domain\Repository\MenuRepositoryInterface;
27use App\Modules\Menu\Infrastructure\Repository\SqlMenuRepository;
28use App\Modules\Profiles\Domain\Model\FieldPermissionType;
29use App\Modules\Profiles\Domain\Repository\ProfilePermissionRepositoryInterface;
30use PDO;
31use Throwable;
32
33/**
34 * Global Search Application Service.
35 *
36 * Implements business logic for searching across CRUD modules respecting RBAC and user preferences.
37 *
38 * @package App\Core\Search\Application\Service
39 */
40final readonly class GlobalSearchService implements GlobalSearchServiceInterface
41{
42    private const string PREF_KEY = 'global_search_settings';
43    private const array SEARCHABLE_UITYPES = [
44        'string_input', 'text_area', 'email_input', 'phone', 'url',
45        'autonumber', 'prefix_number', 'select', 'picklist',
46    ];
47
48    private const array MODULE_LABEL_FALLBACKS = [
49        'calendar'       => 'Kalendarz',
50        'contacts'       => 'Kontakty',
51        'companies'      => 'Firmy',
52        'opportunities'  => 'Szanse sprzedaży',
53        'quotes'         => 'Oferty',
54        'orders'         => 'Zamówienia',
55        'projects'       => 'Projekty',
56        'project_stages' => 'Etapy projektów',
57        'project-stages' => 'Etapy projektów',
58        'project_tasks'  => 'Zadania projektowe',
59        'project-tasks'  => 'Zadania projektowe',
60        'tickets'        => 'Zgłoszenia',
61        'contracts'      => 'Umowy',
62        'documents'      => 'Dokumenty',
63        'products'       => 'Produkty',
64        'services'       => 'Usługi',
65        'sold_products'  => 'Sprzedane produkty',
66        'sold-products'  => 'Sprzedane produkty',
67        'sold_services'  => 'Sprzedane usługi',
68        'sold-services'  => 'Sprzedane usługi',
69        'emails'         => 'Wiadomości e-mail',
70        'work_time'      => 'Czas pracy',
71        'work-time'      => 'Czas pracy',
72        'dashboard'      => 'Pulpit',
73        'partners'       => 'Partnerzy',
74        'users'          => 'Użytkownicy',
75        'structure'      => 'Struktura organizacyjna',
76        'profiles'       => 'Profile uprawnień',
77        'mailboxes'      => 'Skrzynki pocztowe',
78        'taxes'          => 'Podatki i stawki VAT',
79        'currencies'     => 'Waluty',
80    ];
81
82    /**
83     * GlobalSearchService constructor.
84     *
85     * @param MetadataRepositoryInterface            $metadataRepo           System metadata repository.
86     * @param SearchRepositoryInterface              $searchRepo             Search persistence repository.
87     * @param UserPreferenceRepositoryInterface      $userPreferences        User preferences repository.
88     * @param ProfilePermissionRepositoryInterface|null $profileRepo         Profile permission repository.
89     * @param AccessRepositoryInterface|null         $accessRepo             Module access repository.
90     * @param PDO|null                               $pdo                    Database PDO connection.
91     * @param string                                 $tablePrefix            Database table prefix.
92     * @param MenuRepositoryInterface|null           $menuRepo               Menu repository.
93     * @param string                                 $appProfile             Application profile ('admin'|'client').
94     * @param InstanceContextManagerInterface|null   $instanceContextManager Multi-tenant instance context manager.
95     */
96    public function __construct(
97        private MetadataRepositoryInterface            $metadataRepo,
98        private SearchRepositoryInterface              $searchRepo,
99        private UserPreferenceRepositoryInterface      $userPreferences,
100        private ?ProfilePermissionRepositoryInterface $profileRepo = null,
101        private ?AccessRepositoryInterface             $accessRepo = null,
102        private ?PDO                                   $pdo = null,
103        private string                                 $tablePrefix = 'a_',
104        private ?MenuRepositoryInterface               $menuRepo = null,
105        private string                                 $appProfile = 'admin',
106        private ?InstanceContextManagerInterface       $instanceContextManager = null
107    ) {
108    }
109
110    /**
111     * {@inheritdoc}
112     */
113    public function search(SearchQuery $query, PermissionContext $context): SearchResultSet
114    {
115        $startTime = hrtime(true);
116        $settings = $this->getSearchSettings($context->actorUserId);
117        $activeModules = $this->metadataRepo->findAllActiveModules();
118        $targetModules = $this->resolveTargetModules($query, $settings, $activeModules, $context);
119
120        $effectiveLimit = $query->limitPerModule;
121        if ($effectiveLimit === SearchQuery::DEFAULT_LIMIT_PER_MODULE) {
122            $configuredLimit = $this->resolveConfiguredLimit();
123            $effectiveLimit = $configuredLimit ?? $settings->limitPerModule;
124        }
125
126        $effectiveQuery = ($effectiveLimit !== $query->limitPerModule)
127            ? new SearchQuery($query->term, $query->mode, $query->modules, $effectiveLimit)
128            : $query;
129
130        $groups = [];
131        $totalResults = 0;
132        $menuRoutes = $this->resolveAllowedMenuRoutes();
133
134        foreach ($targetModules as $module) {
135            $fields = $this->resolveSearchableFields($module, $context);
136            if (empty($fields)) {
137                continue;
138            }
139
140            $group = $this->searchRepo->searchModule($module, $fields, $effectiveQuery, $context);
141            if ($group->totalCount > 0) {
142                $localizedLabel = $this->resolveModuleLabel($module, $menuRoutes);
143                if ($localizedLabel !== $group->moduleLabel) {
144                    $group = new SearchResultGroup(
145                        moduleName:  $group->moduleName,
146                        moduleLabel: $localizedLabel,
147                        iconClass:   $group->iconClass,
148                        totalCount:  $group->totalCount,
149                        results:     $group->results
150                    );
151                }
152                $groups[] = $group;
153                $totalResults += $group->totalCount;
154            }
155        }
156
157        $durationMs = (hrtime(true) - $startTime) / 1e6;
158
159        return new SearchResultSet(
160            query:           $query->term,
161            mode:            $query->mode->value,
162            groups:          $groups,
163            totalResults:    $totalResults,
164            executionTimeMs: round($durationMs, 2)
165        );
166    }
167
168    /**
169     * {@inheritdoc}
170     */
171    public function getSearchSettings(int $userId): SearchSettings
172    {
173        if ($userId <= 0) {
174            return new SearchSettings();
175        }
176
177        $scope = new PreferenceScope(userId: $userId);
178        $raw = $this->userPreferences->findValue($scope, self::PREF_KEY);
179        $data = $this->parseRawSettings($raw);
180
181        return is_array($data)
182            ? SearchSettings::fromArray($data)
183            : $this->buildFallbackSearchSettings();
184    }
185
186    private function buildFallbackSearchSettings(): SearchSettings
187    {
188        $configuredLimit = $this->resolveConfiguredLimit();
189        if ($configuredLimit !== null && $configuredLimit > 0) {
190            return new SearchSettings(limitPerModule: $configuredLimit);
191        }
192
193        return new SearchSettings();
194    }
195
196    /**
197     * Parses raw settings into an array or null.
198     */
199    private function parseRawSettings(mixed $raw): ?array
200    {
201        if (is_array($raw)) {
202            return $raw;
203        }
204        if (is_string($raw) && $raw !== '') {
205            $decoded = json_decode($raw, true);
206            return is_array($decoded) ? $decoded : null;
207        }
208        return null;
209    }
210
211    /**
212     * {@inheritdoc}
213     */
214    public function saveSearchSettings(int $userId, SearchSettings $settings): void
215    {
216        if ($userId <= 0) {
217            return;
218        }
219
220        $scope = new PreferenceScope(userId: $userId);
221        $pref = UserPreference::create($scope, self::PREF_KEY, $settings->jsonSerialize());
222        $this->userPreferences->save($pref);
223    }
224
225    /**
226     * {@inheritdoc}
227     */
228    public function getSearchableModules(PermissionContext $context): array
229    {
230        $settings = $this->getSearchSettings($context->actorUserId);
231        $activeModules = $this->metadataRepo->findAllActiveModules();
232        $menuRoutes = $this->resolveAllowedMenuRoutes();
233        $result = [];
234
235        foreach ($activeModules as $module) {
236            if (!$this->isModuleEligibleForSearch($module, $context, $menuRoutes)) {
237                continue;
238            }
239
240            $fields = $this->resolveSearchableFields($module, $context);
241            $isEnabled = empty($settings->enabledModules)
242                || in_array($module->name, $settings->enabledModules, true);
243
244            $result[] = [
245                'module_name'  => $module->name,
246                'label'        => $this->resolveModuleLabel($module, $menuRoutes),
247                'icon_class'   => $module->getIconClass(),
248                'is_enabled'   => $isEnabled,
249                'fields_count' => count($fields),
250                'fields'       => [],
251            ];
252        }
253
254        return $result;
255    }
256
257    /**
258     * Resolves accessible CRUD modules to search.
259     *
260     * @param SearchQuery                   $query         Search query.
261     * @param SearchSettings                $settings      User settings.
262     * @param array<string, ModuleMetadata> $activeModules Active modules.
263     * @param PermissionContext             $context       Actor context.
264     * @return list<ModuleMetadata> Filtered modules list.
265     */
266    private function resolveTargetModules(
267        SearchQuery       $query,
268        SearchSettings    $settings,
269        array             $activeModules,
270        PermissionContext $context
271    ): array {
272        $allowed = [];
273        $menuRoutes = $this->resolveAllowedMenuRoutes();
274
275        foreach ($activeModules as $name => $module) {
276            if (!$this->isModuleEligibleForSearch($module, $context, $menuRoutes)) {
277                continue;
278            }
279
280            if (!empty($query->modules) && !in_array($name, $query->modules, true)) {
281                continue;
282            }
283
284            if (empty($query->modules)
285                && !empty($settings->enabledModules)
286                && !in_array($name, $settings->enabledModules, true)) {
287                continue;
288            }
289
290            $allowed[] = $module;
291        }
292
293        return $allowed;
294    }
295
296    /**
297     * Validates whether a module qualifies for global search filtering.
298     *
299     * @param ModuleMetadata      $module     Target module metadata.
300     * @param PermissionContext   $context    Current actor permission context.
301     * @param array<string, bool> $menuRoutes Pre-resolved menu routes map.
302     * @return bool True if eligible for global search.
303     */
304    private function isModuleEligibleForSearch(
305        ModuleMetadata    $module,
306        PermissionContext $context,
307        array             $menuRoutes = []
308    ): bool {
309        if ($module->type !== 'crud') {
310            return false;
311        }
312
313        $effectiveProfile = ($this->instanceContextManager !== null && $this->instanceContextManager->isRemote())
314            ? 'client'
315            : $this->appProfile;
316        $activeHostId = ($effectiveProfile === 'client') ? 2 : 1;
317
318        if (!$module->isAvailableForHost($activeHostId)) {
319            return false;
320        }
321
322        if (!empty($menuRoutes)) {
323            $normalizedRoute = '/' . trim($module->routeUrl, '/');
324            $moduleSlugRoute = '/' . trim($module->name, '/');
325            $modulePathRoute = '/modules/' . trim($module->name, '/');
326            if (!isset($menuRoutes[$normalizedRoute])
327                && !isset($menuRoutes[$moduleSlugRoute])
328                && !isset($menuRoutes[$modulePathRoute])) {
329                return false;
330            }
331        }
332
333        return $this->canUserAccessModule($module, $context);
334    }
335
336    /**
337     * Resolves accessible module route URLs from active menu for the effective application profile.
338     *
339     * @return array<string, bool> Map of normalized routes.
340     */
341    private function resolveAllowedMenuRoutes(): array
342    {
343        $repo = $this->menuRepo;
344        if ($repo === null && $this->pdo !== null) {
345            $repo = new SqlMenuRepository($this->pdo, $this->tablePrefix, $this->appProfile);
346        }
347
348        if ($repo === null) {
349            return [];
350        }
351
352        $effectiveProfile = ($this->instanceContextManager !== null && $this->instanceContextManager->isRemote())
353            ? 'client'
354            : $this->appProfile;
355
356        $menuTree = $repo->getActiveMenuTree($effectiveProfile);
357        $routes = [];
358        $this->extractMenuRoutesRecursively($menuTree, $routes);
359
360        return $routes;
361    }
362
363    /**
364     * Recursively extracts route URLs from menu hierarchy.
365     *
366     * @param list<MenuItem>        $items  Menu items.
367     * @param array<string, string> $routes Accumulated routes map with labels.
368     */
369    private function extractMenuRoutesRecursively(array $items, array &$routes): void
370    {
371        foreach ($items as $item) {
372            $url = $item->getRouteUrl();
373            if ($url !== null && $url !== '') {
374                $path = parse_url($url, PHP_URL_PATH) ?: $url;
375                $routes['/' . trim((string) $path, '/')] = $item->getLabel();
376            }
377            $children = $item->getChildren();
378            if (!empty($children)) {
379                $this->extractMenuRoutesRecursively($children, $routes);
380            }
381        }
382    }
383
384    /**
385     * Resolves human-readable Polish label for a searchable module.
386     *
387     * @param ModuleMetadata        $module     Module metadata entity.
388     * @param array<string, string> $menuRoutes Menu routes map with labels.
389     * @return string Localized module label.
390     */
391    private function resolveModuleLabel(ModuleMetadata $module, array $menuRoutes = []): string
392    {
393        $normalizedRoute = '/' . trim($module->routeUrl, '/');
394        $moduleSlugRoute = '/' . trim($module->name, '/');
395        $modulePathRoute = '/modules/' . trim($module->name, '/');
396
397        if (isset($menuRoutes[$normalizedRoute]) && $menuRoutes[$normalizedRoute] !== '') {
398            return $menuRoutes[$normalizedRoute];
399        }
400        if (isset($menuRoutes[$moduleSlugRoute]) && $menuRoutes[$moduleSlugRoute] !== '') {
401            return $menuRoutes[$moduleSlugRoute];
402        }
403        if (isset($menuRoutes[$modulePathRoute]) && $menuRoutes[$modulePathRoute] !== '') {
404            return $menuRoutes[$modulePathRoute];
405        }
406
407        $cleanName = strtolower(str_replace([' ', '-'], '_', $module->name));
408        if (isset(self::MODULE_LABEL_FALLBACKS[$cleanName])) {
409            return self::MODULE_LABEL_FALLBACKS[$cleanName];
410        }
411
412        return $module->label ?: $module->name;
413    }
414
415    /**
416     * Checks whether the actor has read permission on the module.
417     *
418     * @param ModuleMetadata    $module  Target module.
419     * @param PermissionContext $context Security context.
420     * @return bool True if permitted.
421     */
422    private function canUserAccessModule(ModuleMetadata $module, PermissionContext $context): bool
423    {
424        if ($this->profileRepo !== null
425            && $context->actorProfileId !== null
426            && !$this->profileRepo->canViewModule($context->actorProfileId, $module->name)) {
427            return false;
428        }
429
430        if ($context->isSuperuser) {
431            return true;
432        }
433
434        return $this->accessRepo === null
435            || $this->accessRepo->getModuleLevel($module->name)->isPublic()
436            || $module->hasOwnerScope();
437    }
438
439    /**
440     * Resolves and filters searchable fields for a module considering module metadata and profile.
441     *
442     * @param ModuleMetadata    $module  Module metadata.
443     * @param PermissionContext $context Security context.
444     * @return list<FieldMetadata> Searchable fields.
445     */
446    private function resolveSearchableFields(
447        ModuleMetadata    $module,
448        PermissionContext $context
449    ): array {
450        $searchable = $this->metadataRepo->findGlobalSearchFields($module->id);
451        if (empty($searchable)) {
452            $allFields = $this->metadataRepo->findFields($module->id);
453            foreach ($allFields as $f) {
454                if (!$f->isSystem && $f->isTitleField() && in_array($f->uitypeName, self::SEARCHABLE_UITYPES, true)) {
455                    $searchable[] = $f;
456                }
457            }
458        }
459
460        return $this->filterPermittedFields($searchable, $module, $context);
461    }
462
463    /**
464     * Filters searchable fields by permissions and removes sensitive fields.
465     *
466     * @param list<FieldMetadata> $fields  Searchable fields.
467     * @param ModuleMetadata      $module  Module metadata.
468     * @param PermissionContext   $context Security context.
469     * @return list<FieldMetadata> Filtered fields list.
470     */
471    private function filterPermittedFields(
472        array             $fields,
473        ModuleMetadata    $module,
474        PermissionContext $context
475    ): array {
476        $fieldPerms = [];
477        if (!$context->isSuperuser && $this->profileRepo !== null && $context->actorProfileId !== null) {
478            $fieldPerms = $this->profileRepo->getFieldPermissions($context->actorProfileId, $module->name);
479        }
480
481        $filtered = [];
482        foreach ($fields as $field) {
483            if ($field->isSystem || in_array($field->uitypeName, ['password'], true)) {
484                continue;
485            }
486
487            $permKey = $module->name . '.' . $field->fieldKey;
488            $fPerm = $fieldPerms[$permKey] ?? null;
489            if ($fPerm !== null && $fPerm->permission->isHidden()) {
490                continue;
491            }
492
493            $filtered[] = $field;
494        }
495
496        return $filtered;
497    }
498
499    /**
500     * Resolves configured default search limit from a_core_settings_records table.
501     *
502     * @return int|null Configured limit or null.
503     */
504    private function resolveConfiguredLimit(): ?int
505    {
506        if ($this->pdo === null) {
507            return null;
508        }
509
510        try {
511            $table = $this->tablePrefix . 'core_settings_records';
512            $sql = sprintf('SELECT `setting_value` FROM `%s` WHERE `setting_key` = :k LIMIT 1', $table);
513            $stmt = $this->pdo->prepare($sql);
514            $stmt->execute([':k' => 'global_search_limit']);
515            $val = $stmt->fetchColumn();
516
517            return ($val !== false && is_numeric($val) && (int) $val > 0) ? (int) $val : null;
518        } catch (Throwable) {
519            return null;
520        }
521    }
522}