Skip to content

Commit 1a133a4

Browse files
committed
feat(search): add profile directory search service
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent db7cab9 commit 1a133a4

1 file changed

Lines changed: 150 additions & 0 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace OCA\ProfileFields\Search;
11+
12+
use InvalidArgumentException;
13+
use OCA\ProfileFields\Db\FieldValue;
14+
use OCA\ProfileFields\Db\FieldValueMapper;
15+
use OCA\ProfileFields\Enum\FieldVisibility;
16+
use OCA\ProfileFields\Service\FieldDefinitionService;
17+
use OCP\IGroupManager;
18+
use OCP\IUser;
19+
use OCP\IUserManager;
20+
21+
class ProfileFieldDirectorySearchService {
22+
private const MAX_MATCHES_PER_USER = 3;
23+
24+
public function __construct(
25+
private FieldDefinitionService $fieldDefinitionService,
26+
private FieldValueMapper $fieldValueMapper,
27+
private IUserManager $userManager,
28+
private IGroupManager $groupManager,
29+
) {
30+
}
31+
32+
/**
33+
* @return array{total: int, items: list<array{
34+
* user_uid: string,
35+
* display_name: string,
36+
* matched_fields: list<array{
37+
* field_key: string,
38+
* field_label: string,
39+
* value: string
40+
* }>
41+
* }>}
42+
*/
43+
public function search(?IUser $actor, string $term, int $limit, int $offset): array {
44+
if ($limit < 1) {
45+
throw new InvalidArgumentException('limit must be greater than 0');
46+
}
47+
48+
if ($offset < 0) {
49+
throw new InvalidArgumentException('offset must be greater than or equal to 0');
50+
}
51+
52+
$normalizedTerm = trim(mb_strtolower($term));
53+
if ($normalizedTerm === '') {
54+
return ['total' => 0, 'items' => []];
55+
}
56+
57+
$actorUid = $actor?->getUID();
58+
$actorIsAdmin = $actorUid !== null && $this->groupManager->isAdmin($actorUid);
59+
$definitionsById = [];
60+
foreach ($this->fieldDefinitionService->findActiveOrdered() as $definition) {
61+
$definitionsById[$definition->getId()] = $definition;
62+
}
63+
64+
if ($definitionsById === []) {
65+
return ['total' => 0, 'items' => []];
66+
}
67+
68+
$matchesByUserUid = [];
69+
foreach ($this->fieldValueMapper->findAllOrdered() as $value) {
70+
$definition = $definitionsById[$value->getFieldDefinitionId()] ?? null;
71+
if ($definition === null) {
72+
continue;
73+
}
74+
75+
if (!$this->isSearchableForActor($definition->getUserVisible(), $value->getCurrentVisibility(), $actorIsAdmin, $actorUid !== null)) {
76+
continue;
77+
}
78+
79+
$scalarValue = $this->extractScalarValue($value);
80+
if ($scalarValue === null || !str_contains(mb_strtolower($scalarValue), $normalizedTerm)) {
81+
continue;
82+
}
83+
84+
$userUid = $value->getUserUid();
85+
if (!isset($matchesByUserUid[$userUid])) {
86+
$user = $this->userManager->get($userUid);
87+
$matchesByUserUid[$userUid] = [
88+
'user_uid' => $userUid,
89+
'display_name' => $this->resolveDisplayName($user, $userUid),
90+
'matched_fields' => [],
91+
];
92+
}
93+
94+
if (count($matchesByUserUid[$userUid]['matched_fields']) >= self::MAX_MATCHES_PER_USER) {
95+
continue;
96+
}
97+
98+
$matchesByUserUid[$userUid]['matched_fields'][] = [
99+
'field_key' => $definition->getFieldKey(),
100+
'field_label' => $definition->getLabel(),
101+
'value' => $scalarValue,
102+
];
103+
}
104+
105+
$matches = array_values($matchesByUserUid);
106+
usort($matches, static function (array $left, array $right): int {
107+
return [$left['display_name'], $left['user_uid']] <=> [$right['display_name'], $right['user_uid']];
108+
});
109+
110+
return [
111+
'total' => count($matches),
112+
'items' => array_slice($matches, $offset, $limit),
113+
];
114+
}
115+
116+
private function extractScalarValue(FieldValue $value): ?string {
117+
$decoded = json_decode($value->getValueJson(), true);
118+
$scalar = $decoded['value'] ?? null;
119+
if (is_array($scalar) || is_object($scalar) || $scalar === null) {
120+
return null;
121+
}
122+
123+
return trim((string)$scalar);
124+
}
125+
126+
private function isSearchableForActor(bool $fieldIsUserVisible, string $currentVisibility, bool $actorIsAdmin, bool $actorIsAuthenticated): bool {
127+
if ($actorIsAdmin) {
128+
return true;
129+
}
130+
131+
if (!$fieldIsUserVisible) {
132+
return false;
133+
}
134+
135+
return match (FieldVisibility::from($currentVisibility)) {
136+
FieldVisibility::PUBLIC => true,
137+
FieldVisibility::USERS => $actorIsAuthenticated,
138+
FieldVisibility::PRIVATE => false,
139+
};
140+
}
141+
142+
private function resolveDisplayName(?IUser $user, string $fallbackUserUid): string {
143+
if ($user === null) {
144+
return $fallbackUserUid;
145+
}
146+
147+
$displayName = trim($user->getDisplayName());
148+
return $displayName !== '' ? $displayName : $fallbackUserUid;
149+
}
150+
}

0 commit comments

Comments
 (0)