Skip to content

Commit e092cf0

Browse files
authored
Merge pull request #8 from LibreCodeCoop/feat/f4a-admin-search
feat: f4a admin search
2 parents 2f1a275 + a164ab8 commit e092cf0

File tree

15 files changed

+1349
-5
lines changed

15 files changed

+1349
-5
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l",
3333
"cs:check": "php-cs-fixer fix --dry-run --diff",
3434
"cs:fix": "php-cs-fixer fix",
35-
"openapi": "generate-spec --verbose",
35+
"openapi": "generate-spec --verbose && (npm run typescript:generate || echo 'Please manually regenerate the typescript OpenAPI models')",
3636
"psalm": "psalm --no-cache --threads=$(nproc)",
3737
"psalm:update-baseline": "psalm --threads=$(nproc) --update-baseline --set-baseline=tests/psalm-baseline.xml",
3838
"post-install-cmd": [

lib/Controller/FieldValueAdminApiController.php

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,21 @@
2020
use OCP\AppFramework\Http\DataResponse;
2121
use OCP\AppFramework\OCSController;
2222
use OCP\IRequest;
23+
use OCP\IUser;
24+
use OCP\IUserManager;
2325

2426
/**
2527
* @psalm-import-type ProfileFieldsValuePayload from \OCA\ProfileFields\ResponseDefinitions
28+
* @psalm-import-type ProfileFieldsLookupField from \OCA\ProfileFields\ResponseDefinitions
29+
* @psalm-import-type ProfileFieldsSearchResult from \OCA\ProfileFields\ResponseDefinitions
2630
* @psalm-import-type ProfileFieldsValueRecord from \OCA\ProfileFields\ResponseDefinitions
2731
*/
2832
class FieldValueAdminApiController extends OCSController {
2933
public function __construct(
3034
IRequest $request,
3135
private FieldDefinitionService $fieldDefinitionService,
3236
private FieldValueService $fieldValueService,
37+
private IUserManager $userManager,
3338
private ?string $userId,
3439
) {
3540
parent::__construct(Application::APP_ID, $request);
@@ -41,7 +46,7 @@ public function __construct(
4146
* Return all persisted profile field values for a specific user.
4247
*
4348
* @param string $userUid User identifier whose profile field values should be listed
44-
* @return DataResponse<Http::STATUS_OK, list<ProfileFieldsValueRecord>, array{}>
49+
* @return DataResponse<\OCP\AppFramework\Http::STATUS_OK, list<ProfileFieldsValueRecord>, array{}>
4550
*
4651
* 200: User field values listed successfully
4752
*/
@@ -62,7 +67,7 @@ public function index(string $userUid): DataResponse {
6267
* @param int $fieldDefinitionId Identifier of the field definition
6368
* @param array{value?: string|int|float|bool|null}|string|int|float|bool|null $value Value payload to persist
6469
* @param string|null $currentVisibility Visibility to apply to the stored value
65-
* @return DataResponse<Http::STATUS_OK, ProfileFieldsValueRecord, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_UNAUTHORIZED, array{message: string}, array{}>
70+
* @return DataResponse<\OCP\AppFramework\Http::STATUS_OK, ProfileFieldsValueRecord, array{}>|DataResponse<\OCP\AppFramework\Http::STATUS_BAD_REQUEST|\OCP\AppFramework\Http::STATUS_NOT_FOUND|\OCP\AppFramework\Http::STATUS_UNAUTHORIZED, array{message: string}, array{}>
6671
*
6772
* 200: User field value stored successfully
6873
* 400: Invalid field value payload
@@ -103,7 +108,7 @@ public function upsert(
103108
*
104109
* @param string $fieldKey Immutable key of the lookup field, such as cpf
105110
* @param array{value?: string|int|float|bool|null}|string|int|float|bool|null $fieldValue Value payload to match exactly
106-
* @return DataResponse<Http::STATUS_OK, array{user_uid: string, lookup_field_key: string, fields: array<string, array{definition: array<string, mixed>, value: ProfileFieldsValueRecord}>}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_CONFLICT|Http::STATUS_NOT_FOUND|Http::STATUS_UNAUTHORIZED, array{message: string}, array{}>
111+
* @return DataResponse<\OCP\AppFramework\Http::STATUS_OK, array{user_uid: string, lookup_field_key: string, fields: array<string, array{definition: array<string, mixed>, value: ProfileFieldsValueRecord}>}, array{}>|DataResponse<\OCP\AppFramework\Http::STATUS_BAD_REQUEST|\OCP\AppFramework\Http::STATUS_CONFLICT|\OCP\AppFramework\Http::STATUS_NOT_FOUND|\OCP\AppFramework\Http::STATUS_UNAUTHORIZED, array{message: string}, array{}>
107112
*
108113
* 200: User lookup completed successfully
109114
* 400: Invalid lookup payload
@@ -142,6 +147,62 @@ public function lookup(
142147
return new DataResponse($this->serializeLookupResult($definition, $matches[0]), Http::STATUS_OK);
143148
}
144149

150+
/**
151+
* Search users by one profile field filter
152+
*
153+
* Return a paginated list of users that match one explicit profile field filter. The response
154+
* includes only the field/value pair that produced the match, not the full profile.
155+
*
156+
* @param string $fieldKey Immutable key of the field to filter by
157+
* @param string $operator Explicit search operator, currently `eq` or `contains`
158+
* @param string|null $value Value payload to compare against the stored field value
159+
* @param int $limit Maximum number of users to return in the current page
160+
* @param int $offset Zero-based offset into the matched result set
161+
* @return DataResponse<\OCP\AppFramework\Http::STATUS_OK, ProfileFieldsSearchResult, array{}>|DataResponse<\OCP\AppFramework\Http::STATUS_BAD_REQUEST|\OCP\AppFramework\Http::STATUS_NOT_FOUND|\OCP\AppFramework\Http::STATUS_UNAUTHORIZED, array{message: string}, array{}>
162+
*
163+
* 200: User search completed successfully
164+
* 400: Invalid search filter or pagination values
165+
* 401: Authenticated admin user is required
166+
* 404: Search field definition not found
167+
*/
168+
#[ApiRoute(verb: 'GET', url: '/api/v1/users/search')]
169+
public function search(
170+
string $fieldKey,
171+
string $operator = 'eq',
172+
?string $value = null,
173+
int $limit = 50,
174+
int $offset = 0,
175+
): DataResponse {
176+
if ($this->userId === null) {
177+
return new DataResponse(['message' => 'Authenticated admin user is required'], Http::STATUS_UNAUTHORIZED);
178+
}
179+
180+
$definition = $this->fieldDefinitionService->findByFieldKey($fieldKey);
181+
if ($definition === null || !$definition->getActive()) {
182+
return new DataResponse(['message' => 'Search field definition not found'], Http::STATUS_NOT_FOUND);
183+
}
184+
185+
try {
186+
$search = $this->fieldValueService->searchByDefinition($definition, $operator, $value, $limit, $offset);
187+
} catch (InvalidArgumentException $exception) {
188+
return new DataResponse(['message' => $exception->getMessage()], Http::STATUS_BAD_REQUEST);
189+
}
190+
191+
$items = array_map(
192+
fn (FieldValue $matchedValue): array => $this->serializeSearchItem($definition, $matchedValue),
193+
$search['matches'],
194+
);
195+
196+
return new DataResponse([
197+
'items' => $items,
198+
'pagination' => [
199+
'limit' => $limit,
200+
'offset' => $offset,
201+
'total' => $search['total'],
202+
],
203+
], Http::STATUS_OK);
204+
}
205+
145206
/**
146207
* @return array{user_uid: string, lookup_field_key: string, fields: array<string, array{definition: array<string, mixed>, value: ProfileFieldsValueRecord}>}
147208
*/
@@ -174,4 +235,31 @@ private function serializeLookupResult(FieldDefinition $lookupDefinition, FieldV
174235
'fields' => $fields,
175236
];
176237
}
238+
239+
/**
240+
* @return array{user_uid: string, display_name: string, fields: array<string, ProfileFieldsLookupField>}
241+
*/
242+
private function serializeSearchItem(FieldDefinition $definition, FieldValue $matchedValue): array {
243+
$user = $this->userManager->get($matchedValue->getUserUid());
244+
245+
return [
246+
'user_uid' => $matchedValue->getUserUid(),
247+
'display_name' => $this->resolveDisplayName($user, $matchedValue->getUserUid()),
248+
'fields' => [
249+
$definition->getFieldKey() => [
250+
'definition' => $definition->jsonSerialize(),
251+
'value' => $this->fieldValueService->serializeForResponse($matchedValue),
252+
],
253+
],
254+
];
255+
}
256+
257+
private function resolveDisplayName(?IUser $user, string $fallbackUserUid): string {
258+
if ($user === null) {
259+
return $fallbackUserUid;
260+
}
261+
262+
$displayName = $user->getDisplayName();
263+
return $displayName !== '' ? $displayName : $fallbackUserUid;
264+
}
177265
}

lib/Db/FieldValueMapper.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ public function findByFieldDefinitionIdAndUserUid(int $fieldDefinitionId, string
3434
}
3535
}
3636

37+
/**
38+
* @return list<FieldValue>
39+
*/
40+
public function findByFieldDefinitionId(int $fieldDefinitionId): array {
41+
$qb = $this->db->getQueryBuilder();
42+
$qb->select('*')
43+
->from('profile_fields_values')
44+
->where($qb->expr()->eq('field_definition_id', $qb->createNamedParameter($fieldDefinitionId)))
45+
->orderBy('user_uid', 'ASC')
46+
->addOrderBy('id', 'ASC');
47+
48+
return $this->findEntities($qb);
49+
}
50+
3751
/**
3852
* @return list<FieldValue>
3953
*/

lib/ResponseDefinitions.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@
6363
* lookup_field_key: string,
6464
* fields: array<string, ProfileFieldsLookupField>,
6565
* }
66+
* @psalm-type ProfileFieldsSearchItem = array{
67+
* user_uid: string,
68+
* display_name: string,
69+
* fields: array<string, ProfileFieldsLookupField>,
70+
* }
71+
* @psalm-type ProfileFieldsSearchResult = array{
72+
* items: list<ProfileFieldsSearchItem>,
73+
* pagination: array{limit: int, offset: int, total: int},
74+
* }
6675
*/
6776
final class ResponseDefinitions {
6877
private function __construct() {

lib/Service/FieldValueService.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
use OCA\ProfileFields\Enum\FieldVisibility;
2121

2222
class FieldValueService {
23+
private const SEARCH_OPERATOR_EQ = 'eq';
24+
private const SEARCH_OPERATOR_CONTAINS = 'contains';
25+
private const SEARCH_MAX_LIMIT = 100;
26+
2327
public function __construct(
2428
private FieldValueMapper $fieldValueMapper,
2529
) {
@@ -97,6 +101,48 @@ public function findByDefinitionAndRawValue(FieldDefinition $definition, array|s
97101
);
98102
}
99103

104+
/**
105+
* @param array<string, mixed>|scalar|null $rawValue
106+
* @return array{total: int, matches: list<FieldValue>}
107+
*/
108+
public function searchByDefinition(
109+
FieldDefinition $definition,
110+
string $operator,
111+
array|string|int|float|bool|null $rawValue,
112+
int $limit,
113+
int $offset,
114+
): array {
115+
if ($limit < 1 || $limit > self::SEARCH_MAX_LIMIT) {
116+
throw new InvalidArgumentException(sprintf('limit must be between 1 and %d', self::SEARCH_MAX_LIMIT));
117+
}
118+
119+
if ($offset < 0) {
120+
throw new InvalidArgumentException('offset must be greater than or equal to 0');
121+
}
122+
123+
$normalizedOperator = strtolower(trim($operator));
124+
if (!in_array($normalizedOperator, [self::SEARCH_OPERATOR_EQ, self::SEARCH_OPERATOR_CONTAINS], true)) {
125+
throw new InvalidArgumentException('search operator is not supported');
126+
}
127+
128+
$searchValue = $this->normalizeSearchValue($definition, $normalizedOperator, $rawValue);
129+
$fieldType = FieldType::from($definition->getType());
130+
$matches = array_values(array_filter(
131+
$this->fieldValueMapper->findByFieldDefinitionId($definition->getId()),
132+
fn (FieldValue $candidate): bool => $this->matchesSearchOperator(
133+
$fieldType,
134+
$this->decodeValue($candidate->getValueJson()),
135+
$searchValue,
136+
$normalizedOperator,
137+
),
138+
));
139+
140+
return [
141+
'total' => count($matches),
142+
'matches' => array_slice($matches, $offset, $limit),
143+
];
144+
}
145+
100146
public function updateVisibility(FieldDefinition $definition, string $userUid, string $updatedByUid, string $currentVisibility): FieldValue {
101147
if (!FieldVisibility::isValid($currentVisibility)) {
102148
throw new InvalidArgumentException('current_visibility is not supported');
@@ -189,6 +235,50 @@ private function decodeValue(string $valueJson): array {
189235
return $decoded;
190236
}
191237

238+
/**
239+
* @param array<string, mixed>|scalar|null $rawValue
240+
* @return array{value: mixed}
241+
*/
242+
private function normalizeSearchValue(FieldDefinition $definition, string $operator, array|string|int|float|bool|null $rawValue): array {
243+
if ($operator === self::SEARCH_OPERATOR_EQ) {
244+
return $this->normalizeValue($definition, $rawValue);
245+
}
246+
247+
if (FieldType::from($definition->getType()) !== FieldType::TEXT) {
248+
throw new InvalidArgumentException('contains operator is only supported for text fields');
249+
}
250+
251+
$normalized = $this->normalizeValue($definition, $rawValue);
252+
$value = $normalized['value'] ?? null;
253+
if (!is_string($value) || $value === '') {
254+
throw new InvalidArgumentException('contains operator requires a non-empty text value');
255+
}
256+
257+
return ['value' => $value];
258+
}
259+
260+
/**
261+
* @param array<string, mixed> $candidateValue
262+
* @param array{value: mixed} $searchValue
263+
*/
264+
private function matchesSearchOperator(FieldType $fieldType, array $candidateValue, array $searchValue, string $operator): bool {
265+
if ($operator === self::SEARCH_OPERATOR_EQ) {
266+
return ($candidateValue['value'] ?? null) === ($searchValue['value'] ?? null);
267+
}
268+
269+
if ($fieldType !== FieldType::TEXT) {
270+
return false;
271+
}
272+
273+
$candidateText = $candidateValue['value'] ?? null;
274+
$needle = $searchValue['value'] ?? null;
275+
if (!is_string($candidateText) || !is_string($needle)) {
276+
return false;
277+
}
278+
279+
return str_contains(strtolower($candidateText), strtolower($needle));
280+
}
281+
192282
private function asMutableDateTime(?DateTimeInterface $value = null): DateTime {
193283
if ($value instanceof DateTime) {
194284
return clone $value;

0 commit comments

Comments
 (0)