diff --git a/composer.json b/composer.json index be77b98..16a0ada 100644 --- a/composer.json +++ b/composer.json @@ -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": [ diff --git a/lib/Controller/FieldValueAdminApiController.php b/lib/Controller/FieldValueAdminApiController.php index df40830..a640bac 100644 --- a/lib/Controller/FieldValueAdminApiController.php +++ b/lib/Controller/FieldValueAdminApiController.php @@ -20,9 +20,13 @@ 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 { @@ -30,6 +34,7 @@ public function __construct( IRequest $request, private FieldDefinitionService $fieldDefinitionService, private FieldValueService $fieldValueService, + private IUserManager $userManager, private ?string $userId, ) { parent::__construct(Application::APP_ID, $request); @@ -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, array{}> + * @return DataResponse<\OCP\AppFramework\Http::STATUS_OK, list, array{}> * * 200: User field values listed successfully */ @@ -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|DataResponse + * @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 @@ -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, value: ProfileFieldsValueRecord}>}, array{}>|DataResponse + * @return DataResponse<\OCP\AppFramework\Http::STATUS_OK, array{user_uid: string, lookup_field_key: string, fields: array, 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 @@ -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, value: ProfileFieldsValueRecord}>} */ @@ -174,4 +235,31 @@ private function serializeLookupResult(FieldDefinition $lookupDefinition, FieldV 'fields' => $fields, ]; } + + /** + * @return array{user_uid: string, display_name: string, fields: array} + */ + 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; + } } diff --git a/lib/Db/FieldValueMapper.php b/lib/Db/FieldValueMapper.php index 84548c9..e1053b0 100644 --- a/lib/Db/FieldValueMapper.php +++ b/lib/Db/FieldValueMapper.php @@ -34,6 +34,20 @@ public function findByFieldDefinitionIdAndUserUid(int $fieldDefinitionId, string } } + /** + * @return list + */ + 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 */ diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index e1eb280..cf22311 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -63,6 +63,15 @@ * lookup_field_key: string, * fields: array, * } + * @psalm-type ProfileFieldsSearchItem = array{ + * user_uid: string, + * display_name: string, + * fields: array, + * } + * @psalm-type ProfileFieldsSearchResult = array{ + * items: list, + * pagination: array{limit: int, offset: int, total: int}, + * } */ final class ResponseDefinitions { private function __construct() { diff --git a/lib/Service/FieldValueService.php b/lib/Service/FieldValueService.php index bae4c92..328c3ed 100644 --- a/lib/Service/FieldValueService.php +++ b/lib/Service/FieldValueService.php @@ -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, ) { @@ -97,6 +101,48 @@ public function findByDefinitionAndRawValue(FieldDefinition $definition, array|s ); } + /** + * @param array|scalar|null $rawValue + * @return array{total: int, matches: list} + */ + 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'); @@ -189,6 +235,50 @@ private function decodeValue(string $valueJson): array { return $decoded; } + /** + * @param array|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 $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; diff --git a/openapi-administration.json b/openapi-administration.json index 257bf03..ecf87ec 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -79,6 +79,21 @@ } } }, + "LookupField": { + "type": "object", + "required": [ + "definition", + "value" + ], + "properties": { + "definition": { + "$ref": "#/components/schemas/Definition" + }, + "value": { + "$ref": "#/components/schemas/ValueRecord" + } + } + }, "OCSMeta": { "type": "object", "required": [ @@ -103,6 +118,65 @@ } } }, + "SearchItem": { + "type": "object", + "required": [ + "user_uid", + "display_name", + "fields" + ], + "properties": { + "user_uid": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "fields": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/LookupField" + } + } + } + }, + "SearchResult": { + "type": "object", + "required": [ + "items", + "pagination" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchItem" + } + }, + "pagination": { + "type": "object", + "required": [ + "limit", + "offset", + "total" + ], + "properties": { + "limit": { + "type": "integer", + "format": "int64" + }, + "offset": { + "type": "integer", + "format": "int64" + }, + "total": { + "type": "integer", + "format": "int64" + } + } + } + } + }, "Type": { "type": "string", "enum": [ @@ -1343,6 +1417,229 @@ } } } + }, + "/ocs/v2.php/apps/profile_fields/api/v1/users/search": { + "get": { + "operationId": "field_value_admin_api-search", + "summary": "Search users by one profile field filter", + "description": "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.\nThis endpoint requires admin access", + "tags": [ + "field_value_admin_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "fieldKey", + "in": "query", + "description": "Immutable key of the field to filter by", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "operator", + "in": "query", + "description": "Explicit search operator, currently `eq` or `contains`", + "schema": { + "type": "string", + "default": "eq" + } + }, + { + "name": "value", + "in": "query", + "description": "Value payload to compare against the stored field value", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of users to return in the current page", + "schema": { + "type": "integer", + "format": "int64", + "default": 50 + } + }, + { + "name": "offset", + "in": "query", + "description": "Zero-based offset into the matched result set", + "schema": { + "type": "integer", + "format": "int64", + "default": 0 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "User search completed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SearchResult" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid search filter or pagination values", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Search field definition not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Authenticated admin user is required", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } } }, "tags": [] diff --git a/openapi-full.json b/openapi-full.json index 57f4842..a4130bc 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -193,6 +193,65 @@ } } }, + "SearchItem": { + "type": "object", + "required": [ + "user_uid", + "display_name", + "fields" + ], + "properties": { + "user_uid": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "fields": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/LookupField" + } + } + } + }, + "SearchResult": { + "type": "object", + "required": [ + "items", + "pagination" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchItem" + } + }, + "pagination": { + "type": "object", + "required": [ + "limit", + "offset", + "total" + ], + "properties": { + "limit": { + "type": "integer", + "format": "int64" + }, + "offset": { + "type": "integer", + "format": "int64" + }, + "total": { + "type": "integer", + "format": "int64" + } + } + } + } + }, "Type": { "type": "string", "enum": [ @@ -1434,6 +1493,229 @@ } } }, + "/ocs/v2.php/apps/profile_fields/api/v1/users/search": { + "get": { + "operationId": "field_value_admin_api-search", + "summary": "Search users by one profile field filter", + "description": "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.\nThis endpoint requires admin access", + "tags": [ + "field_value_admin_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "fieldKey", + "in": "query", + "description": "Immutable key of the field to filter by", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "operator", + "in": "query", + "description": "Explicit search operator, currently `eq` or `contains`", + "schema": { + "type": "string", + "default": "eq" + } + }, + { + "name": "value", + "in": "query", + "description": "Value payload to compare against the stored field value", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of users to return in the current page", + "schema": { + "type": "integer", + "format": "int64", + "default": 50 + } + }, + { + "name": "offset", + "in": "query", + "description": "Zero-based offset into the matched result set", + "schema": { + "type": "integer", + "format": "int64", + "default": 0 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "User search completed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SearchResult" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid search filter or pagination values", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Search field definition not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Authenticated admin user is required", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/profile_fields/api/v1/me/values": { "get": { "operationId": "field_value_api-index", diff --git a/package.json b/package.json index 6d07d35..6a6e7c8 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "NODE_ENV=production vite --mode production build", "dev": "NODE_ENV=development vite --mode development build", "screenshots:refresh": "node playwright/generate-screenshots.mjs", - "typescript:generate": "mkdir -p src/types/openapi && openapi-typescript openapi.json -o src/types/openapi/openapi.ts && openapi-typescript openapi-administration.json -o src/types/openapi/openapi-administration.ts && openapi-typescript openapi-full.json -o src/types/openapi/openapi-full.ts", + "typescript:generate": "mkdir -p src/types/openapi && npx openapi-typescript openapi.json -o src/types/openapi/openapi.ts && npx openapi-typescript openapi-administration.json -o src/types/openapi/openapi-administration.ts && npx openapi-typescript openapi-full.json -o src/types/openapi/openapi-full.ts", "watch": "NODE_ENV=development vite --mode development build --watch", "test": "vitest run", "test:watch": "vitest", diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index 845e50b..20bcbf2 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -119,6 +119,27 @@ export interface paths { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/profile_fields/api/v1/users/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search users by one profile field filter + * @description 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. + * This endpoint requires admin access + */ + get: operations["field_value_admin_api-search"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -139,6 +160,10 @@ export interface components { created_at: string; updated_at: string; }; + LookupField: { + definition: components["schemas"]["Definition"]; + value: components["schemas"]["ValueRecord"]; + }; OCSMeta: { status: string; statuscode: number; @@ -146,6 +171,24 @@ export interface components { totalitems?: string; itemsperpage?: string; }; + SearchItem: { + user_uid: string; + display_name: string; + fields: { + [key: string]: components["schemas"]["LookupField"]; + }; + }; + SearchResult: { + items: components["schemas"]["SearchItem"][]; + pagination: { + /** Format: int64 */ + limit: number; + /** Format: int64 */ + offset: number; + /** Format: int64 */ + total: number; + }; + }; /** @enum {string} */ Type: "text" | "number"; ValuePayload: { @@ -674,4 +717,91 @@ export interface operations { }; }; }; + "field_value_admin_api-search": { + parameters: { + query: { + /** @description Immutable key of the field to filter by */ + fieldKey: string; + /** @description Explicit search operator, currently `eq` or `contains` */ + operator?: string; + /** @description Value payload to compare against the stored field value */ + value?: string | null; + /** @description Maximum number of users to return in the current page */ + limit?: number; + /** @description Zero-based offset into the matched result set */ + offset?: number; + }; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User search completed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["SearchResult"]; + }; + }; + }; + }; + /** @description Invalid search filter or pagination values */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Authenticated admin user is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Search field definition not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; } diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index b44d326..29918ed 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -119,6 +119,27 @@ export interface paths { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/profile_fields/api/v1/users/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search users by one profile field filter + * @description 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. + * This endpoint requires admin access + */ + get: operations["field_value_admin_api-search"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/profile_fields/api/v1/me/values": { parameters: { query?: never; @@ -234,6 +255,24 @@ export interface components { totalitems?: string; itemsperpage?: string; }; + SearchItem: { + user_uid: string; + display_name: string; + fields: { + [key: string]: components["schemas"]["LookupField"]; + }; + }; + SearchResult: { + items: components["schemas"]["SearchItem"][]; + pagination: { + /** Format: int64 */ + limit: number; + /** Format: int64 */ + offset: number; + /** Format: int64 */ + total: number; + }; + }; /** @enum {string} */ Type: "text" | "number"; ValuePayload: { @@ -762,6 +801,93 @@ export interface operations { }; }; }; + "field_value_admin_api-search": { + parameters: { + query: { + /** @description Immutable key of the field to filter by */ + fieldKey: string; + /** @description Explicit search operator, currently `eq` or `contains` */ + operator?: string; + /** @description Value payload to compare against the stored field value */ + value?: string | null; + /** @description Maximum number of users to return in the current page */ + limit?: number; + /** @description Zero-based offset into the matched result set */ + offset?: number; + }; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User search completed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["SearchResult"]; + }; + }; + }; + }; + /** @description Invalid search filter or pagination values */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Authenticated admin user is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Search field definition not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; "field_value_api-index": { parameters: { query?: never; diff --git a/tests/integration/features/api/profile_fields.feature b/tests/integration/features/api/profile_fields.feature index f2930b6..d8dfd76 100644 --- a/tests/integration/features/api/profile_fields.feature +++ b/tests/integration/features/api/profile_fields.feature @@ -277,3 +277,72 @@ Feature: profile fields API And the response should be a JSON array with the following mandatory values | key | value | | (jq).ocs.data.message | Multiple users match the lookup field value | + + Scenario: admins can search users by field value with pagination + Given user "profileuser2" exists + And user "profileuser3" exists + And as user "admin" + When sending "post" to ocs "/apps/profile_fields/api/v1/definitions" + | fieldKey | region | + | label | Region | + | type | text | + | adminOnly | false | + | userEditable | false | + | userVisible | true | + | initialVisibility | private | + | sortOrder | 10 | + | active | true | + Then the response should have a status code 201 + And fetch field "(REGION_FIELD_ID)(jq).ocs.data.id" from previous JSON response + When sending "put" to ocs "/apps/profile_fields/api/v1/users/profileuser/values/" + | value | LATAM - South | + | currentVisibility | private | + Then the response should have a status code 200 + When sending "put" to ocs "/apps/profile_fields/api/v1/users/profileuser2/values/" + | value | LATAM - North | + | currentVisibility | private | + Then the response should have a status code 200 + When sending "put" to ocs "/apps/profile_fields/api/v1/users/profileuser3/values/" + | value | EMEA | + | currentVisibility | private | + Then the response should have a status code 200 + When sending "get" to ocs "/apps/profile_fields/api/v1/users/search?fieldKey=region&operator=contains&value=latam&limit=1&offset=1" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.pagination.total | 2 | + | (jq).ocs.data.pagination.limit | 1 | + | (jq).ocs.data.pagination.offset | 1 | + | (jq).ocs.data.items | (jq)length == 1 | + | (jq).ocs.data.items[0].user_uid | profileuser2 | + | (jq).ocs.data.items[0].display_name | profileuser2-displayname | + | (jq).ocs.data.items[0].fields.region.definition.field_key | region | + | (jq).ocs.data.items[0].fields.region.value.value.value | LATAM - North | + | (jq).ocs.data.items[0].fields.region.value.current_visibility | private | + + Scenario: admins get a bad request when search operator is not supported + Given as user "admin" + When sending "post" to ocs "/apps/profile_fields/api/v1/definitions" + | fieldKey | region | + | label | Region | + | type | text | + | adminOnly | false | + | userEditable | false | + | userVisible | true | + | initialVisibility | private | + | sortOrder | 10 | + | active | true | + Then the response should have a status code 201 + When sending "get" to ocs "/apps/profile_fields/api/v1/users/search?fieldKey=region&operator=startsWith&value=latam" + Then the response should have a status code 400 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.message | search operator is not supported | + + Scenario: admins get not found when search field definition does not exist + Given as user "admin" + When sending "get" to ocs "/apps/profile_fields/api/v1/users/search?fieldKey=unknown_region&operator=eq&value=LATAM" + Then the response should have a status code 404 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.message | Search field definition not found | diff --git a/tests/php/Api/Controller/FieldValueAdminApiControllerTest.php b/tests/php/Api/Controller/FieldValueAdminApiControllerTest.php index 5de04fd..c3dfc19 100644 --- a/tests/php/Api/Controller/FieldValueAdminApiControllerTest.php +++ b/tests/php/Api/Controller/FieldValueAdminApiControllerTest.php @@ -70,4 +70,39 @@ public function testAdminValueListMatchesOpenApiContract(): void { $this->assertSame(200, $response->getStatusCode()); } + + /** + * @throws DefinitionNotFoundException + * @throws GenericSwaggerException + * @throws HttpMethodNotFoundException + * @throws InvalidDefinitionException + * @throws InvalidRequestException + * @throws NotMatchedException + * @throws PathNotFoundException + * @throws RequiredArgumentNotFound + * @throws StatusCodeNotMatchedException + */ + public function testAdminSearchMatchesOpenApiContract(): void { + $fieldKey = $this->uniqueFieldKey('api_admin_search_contract_field'); + $definition = $this->createDefinition( + $fieldKey, + 'Admin API search contract field', + FieldType::TEXT->value, + false, + true, + FieldVisibility::PUBLIC->value, + 20, + true, + ); + $this->createStoredValue($definition, self::OWNER_USER_ID, 'LATAM', self::ADMIN_USER_ID, FieldVisibility::PUBLIC->value); + + $response = $this->withBasicAuth($this->newApiRequester(), self::ADMIN_USER_ID, self::ADMIN_PASSWORD) + ->withMethod('GET') + ->withPath('/ocs/v2.php/apps/profile_fields/api/v1/users/search?fieldKey=' . rawurlencode($fieldKey) . '&operator=contains&value=lat') + ->assertResponseCode(200) + ->assertBodyContains(self::OWNER_USER_ID) + ->send(); + + $this->assertSame(200, $response->getStatusCode()); + } } diff --git a/tests/php/ControllerIntegration/Controller/FieldValueAdminApiControllerTest.php b/tests/php/ControllerIntegration/Controller/FieldValueAdminApiControllerTest.php index ad982d9..8a5d027 100644 --- a/tests/php/ControllerIntegration/Controller/FieldValueAdminApiControllerTest.php +++ b/tests/php/ControllerIntegration/Controller/FieldValueAdminApiControllerTest.php @@ -99,6 +99,7 @@ public function testAdminValueFlow(): void { $this->createMock(IRequest::class), $this->fieldDefinitionService, $this->fieldValueService, + $this->userManager, $this->currentUserId, ); @@ -152,6 +153,7 @@ public function testAdminCanLookupUserByCpfAndRetrieveCooperativeFields(): void $this->createMock(IRequest::class), $this->fieldDefinitionService, $this->fieldValueService, + $this->userManager, $this->currentUserId, ); @@ -163,6 +165,49 @@ public function testAdminCanLookupUserByCpfAndRetrieveCooperativeFields(): void $this->assertSame(['value' => 'coop-premium'], $response->getData()['fields']['health_plan_type_lookup_integration']['value']['value']); } + public function testAdminCanSearchUsersByFieldWithPagination(): void { + $this->currentUserId = $this->createUser('pf_admin_search'); + $firstOwnerId = $this->createUser('pf_owner_search_a'); + $secondOwnerId = $this->createUser('pf_owner_search_b'); + $thirdOwnerId = $this->createUser('pf_owner_search_c'); + + $definition = $this->fieldDefinitionService->create([ + 'field_key' => 'region_search_integration', + 'label' => 'Region', + 'type' => FieldType::TEXT->value, + 'admin_only' => false, + 'user_editable' => false, + 'user_visible' => true, + 'initial_visibility' => FieldVisibility::PRIVATE->value, + 'sort_order' => 0, + 'active' => true, + ]); + $this->rememberDefinition($definition->getId()); + + $this->fieldValueService->upsert($definition, $firstOwnerId, 'LATAM - South', $this->currentUserId, FieldVisibility::PRIVATE->value); + $this->fieldValueService->upsert($definition, $secondOwnerId, 'LATAM - North', $this->currentUserId, FieldVisibility::PRIVATE->value); + $this->fieldValueService->upsert($definition, $thirdOwnerId, 'EMEA', $this->currentUserId, FieldVisibility::PRIVATE->value); + + $controller = new FieldValueAdminApiController( + $this->createMock(IRequest::class), + $this->fieldDefinitionService, + $this->fieldValueService, + $this->userManager, + $this->currentUserId, + ); + + $response = $controller->search('region_search_integration', 'contains', 'latam', 1, 1); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(2, $response->getData()['pagination']['total']); + $this->assertSame(1, $response->getData()['pagination']['limit']); + $this->assertSame(1, $response->getData()['pagination']['offset']); + $this->assertCount(1, $response->getData()['items']); + $this->assertSame($secondOwnerId, $response->getData()['items'][0]['user_uid']); + $this->assertSame($secondOwnerId, $response->getData()['items'][0]['display_name']); + $this->assertSame(['value' => 'LATAM - North'], $response->getData()['items'][0]['fields']['region_search_integration']['value']['value']); + } + private function rememberDefinition(int $definitionId): void { $definition = $this->fieldDefinitionMapper->findById($definitionId); if ($definition !== null) { diff --git a/tests/php/Unit/Controller/FieldValueAdminApiControllerTest.php b/tests/php/Unit/Controller/FieldValueAdminApiControllerTest.php index ca903f4..433de50 100644 --- a/tests/php/Unit/Controller/FieldValueAdminApiControllerTest.php +++ b/tests/php/Unit/Controller/FieldValueAdminApiControllerTest.php @@ -20,6 +20,8 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; use OCP\IRequest; +use OCP\IUser; +use OCP\IUserManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -27,6 +29,7 @@ class FieldValueAdminApiControllerTest extends TestCase { private IRequest&MockObject $request; private FieldDefinitionService&MockObject $fieldDefinitionService; private FieldValueService&MockObject $fieldValueService; + private IUserManager&MockObject $userManager; private FieldValueAdminApiController $controller; protected function setUp(): void { @@ -34,10 +37,12 @@ protected function setUp(): void { $this->request = $this->createMock(IRequest::class); $this->fieldDefinitionService = $this->createMock(FieldDefinitionService::class); $this->fieldValueService = $this->createMock(FieldValueService::class); + $this->userManager = $this->createMock(IUserManager::class); $this->controller = new FieldValueAdminApiController( $this->request, $this->fieldDefinitionService, $this->fieldValueService, + $this->userManager, 'admin', ); } @@ -86,6 +91,7 @@ public function testUpsertReturnsUnauthorizedWithoutAuthenticatedAdminUser(): vo $this->request, $this->fieldDefinitionService, $this->fieldValueService, + $this->userManager, null, ); @@ -228,6 +234,72 @@ public function testLookupReturnsConflictWhenMoreThanOneUserMatches(): void { $this->assertSame(['message' => 'Multiple users match the lookup field value'], $response->getData()); } + public function testSearchReturnsPaginatedMatchesWithDisplayNames(): void { + $definition = $this->buildDefinition(); + $definition->setFieldKey('region'); + $firstMatch = $this->buildValue(); + $secondMatch = $this->buildValue(); + $secondMatch->setId(4); + $secondMatch->setUserUid('bob'); + $secondMatch->setValueJson('{"value":"LATAM 2"}'); + + $alice = $this->createMock(IUser::class); + $alice->method('getDisplayName')->willReturn('Alice Doe'); + $bob = $this->createMock(IUser::class); + $bob->method('getDisplayName')->willReturn('Bob Doe'); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('region') + ->willReturn($definition); + $this->fieldValueService->expects($this->once()) + ->method('searchByDefinition') + ->with($definition, 'contains', 'latam', 1, 1) + ->willReturn([ + 'total' => 2, + 'matches' => [$secondMatch], + ]); + $this->fieldValueService->expects($this->once()) + ->method('serializeForResponse') + ->with($secondMatch) + ->willReturn([ + 'id' => 4, + 'field_definition_id' => 7, + 'user_uid' => 'bob', + 'value' => ['value' => 'LATAM 2'], + 'current_visibility' => 'users', + 'updated_by_uid' => 'admin', + 'updated_at' => $secondMatch->getUpdatedAt()->format(DATE_ATOM), + ]); + $this->userManager->expects($this->once()) + ->method('get') + ->with('bob') + ->willReturn($bob); + + $response = $this->controller->search('region', 'contains', 'latam', 1, 1); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(2, $response->getData()['pagination']['total']); + $this->assertSame(1, $response->getData()['pagination']['limit']); + $this->assertSame(1, $response->getData()['pagination']['offset']); + $this->assertCount(1, $response->getData()['items']); + $this->assertSame('bob', $response->getData()['items'][0]['user_uid']); + $this->assertSame('Bob Doe', $response->getData()['items'][0]['display_name']); + $this->assertSame(['value' => 'LATAM 2'], $response->getData()['items'][0]['fields']['region']['value']['value']); + } + + public function testSearchReturnsNotFoundWhenDefinitionDoesNotExist(): void { + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('region') + ->willReturn(null); + + $response = $this->controller->search('region', 'eq', 'LATAM'); + + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + $this->assertSame(['message' => 'Search field definition not found'], $response->getData()); + } + private function buildDefinition(): FieldDefinition { $definition = new FieldDefinition(); $definition->setId(7); diff --git a/tests/php/Unit/Service/FieldValueServiceTest.php b/tests/php/Unit/Service/FieldValueServiceTest.php index 64995c1..50ee731 100644 --- a/tests/php/Unit/Service/FieldValueServiceTest.php +++ b/tests/php/Unit/Service/FieldValueServiceTest.php @@ -185,6 +185,93 @@ public function testUpdateVisibilityRejectsMissingValue(): void { $this->service->updateVisibility($definition, 'alice', 'admin', 'users'); } + public function testSearchByDefinitionReturnsPaginatedExactMatches(): void { + $definition = $this->buildDefinition(FieldType::TEXT->value); + $definition->setId(3); + + $firstMatch = new FieldValue(); + $firstMatch->setId(10); + $firstMatch->setFieldDefinitionId(3); + $firstMatch->setUserUid('alice'); + $firstMatch->setValueJson('{"value":"LATAM"}'); + $firstMatch->setCurrentVisibility('users'); + $firstMatch->setUpdatedByUid('admin'); + $firstMatch->setUpdatedAt(new \DateTime()); + + $secondMatch = new FieldValue(); + $secondMatch->setId(11); + $secondMatch->setFieldDefinitionId(3); + $secondMatch->setUserUid('bob'); + $secondMatch->setValueJson('{"value":"LATAM"}'); + $secondMatch->setCurrentVisibility('users'); + $secondMatch->setUpdatedByUid('admin'); + $secondMatch->setUpdatedAt(new \DateTime()); + + $nonMatch = new FieldValue(); + $nonMatch->setId(12); + $nonMatch->setFieldDefinitionId(3); + $nonMatch->setUserUid('carol'); + $nonMatch->setValueJson('{"value":"EMEA"}'); + $nonMatch->setCurrentVisibility('users'); + $nonMatch->setUpdatedByUid('admin'); + $nonMatch->setUpdatedAt(new \DateTime()); + + $this->fieldValueMapper->expects($this->once()) + ->method('findByFieldDefinitionId') + ->with(3) + ->willReturn([$firstMatch, $secondMatch, $nonMatch]); + + $result = $this->service->searchByDefinition($definition, 'eq', 'LATAM', 1, 1); + + $this->assertSame(2, $result['total']); + $this->assertCount(1, $result['matches']); + $this->assertSame('bob', $result['matches'][0]->getUserUid()); + } + + public function testSearchByDefinitionSupportsContainsForTextFields(): void { + $definition = $this->buildDefinition(FieldType::TEXT->value); + $definition->setId(3); + + $match = new FieldValue(); + $match->setId(10); + $match->setFieldDefinitionId(3); + $match->setUserUid('alice'); + $match->setValueJson('{"value":"Ops LATAM"}'); + $match->setCurrentVisibility('users'); + $match->setUpdatedByUid('admin'); + $match->setUpdatedAt(new \DateTime()); + + $nonMatch = new FieldValue(); + $nonMatch->setId(11); + $nonMatch->setFieldDefinitionId(3); + $nonMatch->setUserUid('bob'); + $nonMatch->setValueJson('{"value":"EMEA"}'); + $nonMatch->setCurrentVisibility('users'); + $nonMatch->setUpdatedByUid('admin'); + $nonMatch->setUpdatedAt(new \DateTime()); + + $this->fieldValueMapper->expects($this->once()) + ->method('findByFieldDefinitionId') + ->with(3) + ->willReturn([$match, $nonMatch]); + + $result = $this->service->searchByDefinition($definition, 'contains', 'latam', 50, 0); + + $this->assertSame(1, $result['total']); + $this->assertCount(1, $result['matches']); + $this->assertSame('alice', $result['matches'][0]->getUserUid()); + } + + public function testSearchByDefinitionRejectsUnsupportedOperator(): void { + $definition = $this->buildDefinition(FieldType::TEXT->value); + $definition->setId(3); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('search operator is not supported'); + + $this->service->searchByDefinition($definition, 'starts_with', 'lat', 50, 0); + } + private function buildDefinition(string $type): FieldDefinition { $definition = new FieldDefinition(); $definition->setType($type);