Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l",
"cs:check": "php-cs-fixer fix --dry-run --diff",
"cs:fix": "php-cs-fixer fix",
"openapi": "generate-spec --verbose",
"openapi": "generate-spec --verbose && (npm run typescript:generate || echo 'Please manually regenerate the typescript OpenAPI models')",
"psalm": "psalm --no-cache --threads=$(nproc)",
"psalm:update-baseline": "psalm --threads=$(nproc) --update-baseline --set-baseline=tests/psalm-baseline.xml",
"post-install-cmd": [
Expand Down
94 changes: 91 additions & 3 deletions lib/Controller/FieldValueAdminApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,21 @@
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserManager;

/**
* @psalm-import-type ProfileFieldsValuePayload from \OCA\ProfileFields\ResponseDefinitions
* @psalm-import-type ProfileFieldsLookupField from \OCA\ProfileFields\ResponseDefinitions
* @psalm-import-type ProfileFieldsSearchResult from \OCA\ProfileFields\ResponseDefinitions
* @psalm-import-type ProfileFieldsValueRecord from \OCA\ProfileFields\ResponseDefinitions
*/
class FieldValueAdminApiController extends OCSController {
public function __construct(
IRequest $request,
private FieldDefinitionService $fieldDefinitionService,
private FieldValueService $fieldValueService,
private IUserManager $userManager,
private ?string $userId,
) {
parent::__construct(Application::APP_ID, $request);
Expand All @@ -41,7 +46,7 @@ public function __construct(
* Return all persisted profile field values for a specific user.
*
* @param string $userUid User identifier whose profile field values should be listed
* @return DataResponse<Http::STATUS_OK, list<ProfileFieldsValueRecord>, array{}>
* @return DataResponse<\OCP\AppFramework\Http::STATUS_OK, list<ProfileFieldsValueRecord>, array{}>
*
* 200: User field values listed successfully
*/
Expand All @@ -62,7 +67,7 @@ public function index(string $userUid): DataResponse {
* @param int $fieldDefinitionId Identifier of the field definition
* @param array{value?: string|int|float|bool|null}|string|int|float|bool|null $value Value payload to persist
* @param string|null $currentVisibility Visibility to apply to the stored value
* @return DataResponse<Http::STATUS_OK, ProfileFieldsValueRecord, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_UNAUTHORIZED, array{message: string}, array{}>
* @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{}>
*
* 200: User field value stored successfully
* 400: Invalid field value payload
Expand Down Expand Up @@ -103,7 +108,7 @@ public function upsert(
*
* @param string $fieldKey Immutable key of the lookup field, such as cpf
* @param array{value?: string|int|float|bool|null}|string|int|float|bool|null $fieldValue Value payload to match exactly
* @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{}>
* @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{}>
*
* 200: User lookup completed successfully
* 400: Invalid lookup payload
Expand Down Expand Up @@ -142,6 +147,62 @@ public function lookup(
return new DataResponse($this->serializeLookupResult($definition, $matches[0]), Http::STATUS_OK);
}

/**
* Search users by one profile field filter
*
* Return a paginated list of users that match one explicit profile field filter. The response
* includes only the field/value pair that produced the match, not the full profile.
*
* @param string $fieldKey Immutable key of the field to filter by
* @param string $operator Explicit search operator, currently `eq` or `contains`
* @param string|null $value Value payload to compare against the stored field value
* @param int $limit Maximum number of users to return in the current page
* @param int $offset Zero-based offset into the matched result set
* @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{}>
*
* 200: User search completed successfully
* 400: Invalid search filter or pagination values
* 401: Authenticated admin user is required
* 404: Search field definition not found
*/
#[ApiRoute(verb: 'GET', url: '/api/v1/users/search')]
public function search(
string $fieldKey,
string $operator = 'eq',
?string $value = null,
int $limit = 50,
int $offset = 0,
): DataResponse {
if ($this->userId === null) {
return new DataResponse(['message' => 'Authenticated admin user is required'], Http::STATUS_UNAUTHORIZED);
}

$definition = $this->fieldDefinitionService->findByFieldKey($fieldKey);
if ($definition === null || !$definition->getActive()) {
return new DataResponse(['message' => 'Search field definition not found'], Http::STATUS_NOT_FOUND);
}

try {
$search = $this->fieldValueService->searchByDefinition($definition, $operator, $value, $limit, $offset);
} catch (InvalidArgumentException $exception) {
return new DataResponse(['message' => $exception->getMessage()], Http::STATUS_BAD_REQUEST);
}

$items = array_map(
fn (FieldValue $matchedValue): array => $this->serializeSearchItem($definition, $matchedValue),
$search['matches'],
);

return new DataResponse([
'items' => $items,
'pagination' => [
'limit' => $limit,
'offset' => $offset,
'total' => $search['total'],
],
], Http::STATUS_OK);
}

/**
* @return array{user_uid: string, lookup_field_key: string, fields: array<string, array{definition: array<string, mixed>, value: ProfileFieldsValueRecord}>}
*/
Expand Down Expand Up @@ -174,4 +235,31 @@ private function serializeLookupResult(FieldDefinition $lookupDefinition, FieldV
'fields' => $fields,
];
}

/**
* @return array{user_uid: string, display_name: string, fields: array<string, ProfileFieldsLookupField>}
*/
private function serializeSearchItem(FieldDefinition $definition, FieldValue $matchedValue): array {
$user = $this->userManager->get($matchedValue->getUserUid());

return [
'user_uid' => $matchedValue->getUserUid(),
'display_name' => $this->resolveDisplayName($user, $matchedValue->getUserUid()),
'fields' => [
$definition->getFieldKey() => [
'definition' => $definition->jsonSerialize(),
'value' => $this->fieldValueService->serializeForResponse($matchedValue),
],
],
];
}

private function resolveDisplayName(?IUser $user, string $fallbackUserUid): string {
if ($user === null) {
return $fallbackUserUid;
}

$displayName = $user->getDisplayName();
return $displayName !== '' ? $displayName : $fallbackUserUid;
}
}
14 changes: 14 additions & 0 deletions lib/Db/FieldValueMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ public function findByFieldDefinitionIdAndUserUid(int $fieldDefinitionId, string
}
}

/**
* @return list<FieldValue>
*/
public function findByFieldDefinitionId(int $fieldDefinitionId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from('profile_fields_values')
->where($qb->expr()->eq('field_definition_id', $qb->createNamedParameter($fieldDefinitionId)))
->orderBy('user_uid', 'ASC')
->addOrderBy('id', 'ASC');

return $this->findEntities($qb);
}

/**
* @return list<FieldValue>
*/
Expand Down
9 changes: 9 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@
* lookup_field_key: string,
* fields: array<string, ProfileFieldsLookupField>,
* }
* @psalm-type ProfileFieldsSearchItem = array{
* user_uid: string,
* display_name: string,
* fields: array<string, ProfileFieldsLookupField>,
* }
* @psalm-type ProfileFieldsSearchResult = array{
* items: list<ProfileFieldsSearchItem>,
* pagination: array{limit: int, offset: int, total: int},
* }
*/
final class ResponseDefinitions {
private function __construct() {
Expand Down
90 changes: 90 additions & 0 deletions lib/Service/FieldValueService.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
use OCA\ProfileFields\Enum\FieldVisibility;

class FieldValueService {
private const SEARCH_OPERATOR_EQ = 'eq';
private const SEARCH_OPERATOR_CONTAINS = 'contains';
private const SEARCH_MAX_LIMIT = 100;

public function __construct(
private FieldValueMapper $fieldValueMapper,
) {
Expand Down Expand Up @@ -97,6 +101,48 @@ public function findByDefinitionAndRawValue(FieldDefinition $definition, array|s
);
}

/**
* @param array<string, mixed>|scalar|null $rawValue
* @return array{total: int, matches: list<FieldValue>}
*/
public function searchByDefinition(
FieldDefinition $definition,
string $operator,
array|string|int|float|bool|null $rawValue,
int $limit,
int $offset,
): array {
if ($limit < 1 || $limit > self::SEARCH_MAX_LIMIT) {
throw new InvalidArgumentException(sprintf('limit must be between 1 and %d', self::SEARCH_MAX_LIMIT));
}

if ($offset < 0) {
throw new InvalidArgumentException('offset must be greater than or equal to 0');
}

$normalizedOperator = strtolower(trim($operator));
if (!in_array($normalizedOperator, [self::SEARCH_OPERATOR_EQ, self::SEARCH_OPERATOR_CONTAINS], true)) {
throw new InvalidArgumentException('search operator is not supported');
}

$searchValue = $this->normalizeSearchValue($definition, $normalizedOperator, $rawValue);
$fieldType = FieldType::from($definition->getType());
$matches = array_values(array_filter(
$this->fieldValueMapper->findByFieldDefinitionId($definition->getId()),
fn (FieldValue $candidate): bool => $this->matchesSearchOperator(
$fieldType,
$this->decodeValue($candidate->getValueJson()),
$searchValue,
$normalizedOperator,
),
));

return [
'total' => count($matches),
'matches' => array_slice($matches, $offset, $limit),
];
}

public function updateVisibility(FieldDefinition $definition, string $userUid, string $updatedByUid, string $currentVisibility): FieldValue {
if (!FieldVisibility::isValid($currentVisibility)) {
throw new InvalidArgumentException('current_visibility is not supported');
Expand Down Expand Up @@ -189,6 +235,50 @@ private function decodeValue(string $valueJson): array {
return $decoded;
}

/**
* @param array<string, mixed>|scalar|null $rawValue
* @return array{value: mixed}
*/
private function normalizeSearchValue(FieldDefinition $definition, string $operator, array|string|int|float|bool|null $rawValue): array {
if ($operator === self::SEARCH_OPERATOR_EQ) {
return $this->normalizeValue($definition, $rawValue);
}

if (FieldType::from($definition->getType()) !== FieldType::TEXT) {
throw new InvalidArgumentException('contains operator is only supported for text fields');
}

$normalized = $this->normalizeValue($definition, $rawValue);
$value = $normalized['value'] ?? null;
if (!is_string($value) || $value === '') {
throw new InvalidArgumentException('contains operator requires a non-empty text value');
}

return ['value' => $value];
}

/**
* @param array<string, mixed> $candidateValue
* @param array{value: mixed} $searchValue
*/
private function matchesSearchOperator(FieldType $fieldType, array $candidateValue, array $searchValue, string $operator): bool {
if ($operator === self::SEARCH_OPERATOR_EQ) {
return ($candidateValue['value'] ?? null) === ($searchValue['value'] ?? null);
}

if ($fieldType !== FieldType::TEXT) {
return false;
}

$candidateText = $candidateValue['value'] ?? null;
$needle = $searchValue['value'] ?? null;
if (!is_string($candidateText) || !is_string($needle)) {
return false;
}

return str_contains(strtolower($candidateText), strtolower($needle));
}

private function asMutableDateTime(?DateTimeInterface $value = null): DateTime {
if ($value instanceof DateTime) {
return clone $value;
Expand Down
Loading
Loading