Skip to content
Merged
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ Profile fields lets teams add organization-specific profile data that does not f

This makes the app useful for internal directories, support operations, partner programs and other corporate deployments that need richer account metadata without leaving Nextcloud.

## Features

- Central field catalog for organization-specific profile data.
- Per-field governance for admin-managed and self-service updates.
- Per-value visibility controls for public, authenticated-user, and private exposure.
- User administration tools for reviewing and updating profile field values.
- Workflow integration based on custom profile metadata.
- Global search results built only from fields and values exposed to the current user.

## API documentation

The public API contract for this app is published as [openapi-full.json](https://github.com/LibreCodeCoop/profile_fields/blob/main/openapi-full.json).
Expand Down
2 changes: 2 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use OCA\ProfileFields\Listener\RegisterWorkflowOperationListener;
use OCA\ProfileFields\Listener\UserDeletedCleanupListener;
use OCA\ProfileFields\Notification\Notifier;
use OCA\ProfileFields\Search\ProfileFieldSearchProvider;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
Expand All @@ -42,6 +43,7 @@ public function __construct() {
#[\Override]
public function register(IRegistrationContext $context): void {
$context->registerNotifierService(Notifier::class);
$context->registerSearchProvider(ProfileFieldSearchProvider::class);
$context->registerEventListener('\\OCA\\Settings\\Events\\BeforeTemplateRenderedEvent', BeforeTemplateRenderedListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserDeletedCleanupListener::class);
$context->registerEventListener(RegisterEntitiesEvent::class, RegisterWorkflowEntityListener::class);
Expand Down
150 changes: 150 additions & 0 deletions lib/Search/ProfileFieldDirectorySearchService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare(strict_types=1);

namespace OCA\ProfileFields\Search;

use InvalidArgumentException;
use OCA\ProfileFields\Db\FieldValue;
use OCA\ProfileFields\Db\FieldValueMapper;
use OCA\ProfileFields\Enum\FieldVisibility;
use OCA\ProfileFields\Service\FieldDefinitionService;
use OCP\IGroupManager;
use OCP\IUser;
use OCP\IUserManager;

class ProfileFieldDirectorySearchService {
private const MAX_MATCHES_PER_USER = 3;

public function __construct(
private FieldDefinitionService $fieldDefinitionService,
private FieldValueMapper $fieldValueMapper,
private IUserManager $userManager,
private IGroupManager $groupManager,
) {
}

/**
* @return array{total: int, items: list<array{
* user_uid: string,
* display_name: string,
* matched_fields: list<array{
* field_key: string,
* field_label: string,
* value: string
* }>
* }>}
*/
public function search(?IUser $actor, string $term, int $limit, int $offset): array {
if ($limit < 1) {
throw new InvalidArgumentException('limit must be greater than 0');
}

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

$normalizedTerm = trim(mb_strtolower($term));
if ($normalizedTerm === '') {
return ['total' => 0, 'items' => []];
}

$actorUid = $actor?->getUID();
$actorIsAdmin = $actorUid !== null && $this->groupManager->isAdmin($actorUid);
$definitionsById = [];
foreach ($this->fieldDefinitionService->findActiveOrdered() as $definition) {
$definitionsById[$definition->getId()] = $definition;
}

if ($definitionsById === []) {
return ['total' => 0, 'items' => []];
}

$matchesByUserUid = [];
foreach ($this->fieldValueMapper->findAllOrdered() as $value) {
$definition = $definitionsById[$value->getFieldDefinitionId()] ?? null;
if ($definition === null) {
continue;
}

if (!$this->isSearchableForActor($definition->getUserVisible(), $value->getCurrentVisibility(), $actorIsAdmin, $actorUid !== null)) {
continue;
}

$scalarValue = $this->extractScalarValue($value);
if ($scalarValue === null || !str_contains(mb_strtolower($scalarValue), $normalizedTerm)) {
continue;
}

$userUid = $value->getUserUid();
if (!isset($matchesByUserUid[$userUid])) {
$user = $this->userManager->get($userUid);
$matchesByUserUid[$userUid] = [
'user_uid' => $userUid,
'display_name' => $this->resolveDisplayName($user, $userUid),
'matched_fields' => [],
];
}

if (count($matchesByUserUid[$userUid]['matched_fields']) >= self::MAX_MATCHES_PER_USER) {
continue;
}

$matchesByUserUid[$userUid]['matched_fields'][] = [
'field_key' => $definition->getFieldKey(),
'field_label' => $definition->getLabel(),
'value' => $scalarValue,
];
}

$matches = array_values($matchesByUserUid);
usort($matches, static function (array $left, array $right): int {
return [$left['display_name'], $left['user_uid']] <=> [$right['display_name'], $right['user_uid']];
});

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

private function extractScalarValue(FieldValue $value): ?string {
$decoded = json_decode($value->getValueJson(), true);
$scalar = $decoded['value'] ?? null;
if (is_array($scalar) || is_object($scalar) || $scalar === null) {
return null;
}

return trim((string)$scalar);
}

private function isSearchableForActor(bool $fieldIsUserVisible, string $currentVisibility, bool $actorIsAdmin, bool $actorIsAuthenticated): bool {
if ($actorIsAdmin) {
return true;
}

if (!$fieldIsUserVisible) {
return false;
}

return match (FieldVisibility::from($currentVisibility)) {
FieldVisibility::PUBLIC => true,
FieldVisibility::USERS => $actorIsAuthenticated,
FieldVisibility::PRIVATE => false,
};
}

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

$displayName = trim($user->getDisplayName());
return $displayName !== '' ? $displayName : $fallbackUserUid;
}
}
121 changes: 121 additions & 0 deletions lib/Search/ProfileFieldSearchProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare(strict_types=1);

namespace OCA\ProfileFields\Search;

use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\Search\IProvider;
use OCP\Search\ISearchQuery;
use OCP\Search\SearchResult;
use OCP\Search\SearchResultEntry;

class ProfileFieldSearchProvider implements IProvider {
private const MIN_SEARCH_LENGTH = 2;

public function __construct(
private IL10N $l10n,
private IURLGenerator $urlGenerator,
private ProfileFieldDirectorySearchService $searchService,
) {
}

#[\Override]
public function getId(): string {
return 'profile_fields.directory';
}

#[\Override]
public function getName(): string {
return $this->l10n->t('Profile directory');
}

#[\Override]
public function getOrder(string $route, array $routeParameters): ?int {
return str_starts_with($route, 'settings.Users.usersList') ? 35 : 65;
}

#[\Override]
public function search(IUser $user, ISearchQuery $query): SearchResult {
$term = trim($query->getTerm());
if (mb_strlen($term) < self::MIN_SEARCH_LENGTH) {
return SearchResult::complete($this->getName(), []);
}

$cursor = $this->normalizeCursor($query->getCursor());
$result = $this->searchService->search($user, $term, $query->getLimit(), $cursor);
$entries = array_map(fn (array $item): SearchResultEntry => $this->buildEntry($item), $result['items']);
if ($cursor + count($entries) >= $result['total']) {
return SearchResult::complete($this->getName(), $entries);
}

return SearchResult::paginated(
$this->getName(),
$entries,
$cursor + count($entries),
);
}

private function normalizeCursor(int|string|null $cursor): int {
if ($cursor === null || $cursor === '') {
return 0;
}

if (is_int($cursor)) {
return $cursor;
}

if (preg_match('/^-?\d+$/', $cursor) === 1) {
return (int)$cursor;
}

return 0;
}

/**
* @param array{
* user_uid: string,
* display_name: string,
* matched_fields: list<array{
* field_key: string,
* field_label: string,
* value: string
* }>
* } $item
*/
private function buildEntry(array $item): SearchResultEntry {
$thumbnailUrl = $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', [
'userId' => $item['user_uid'],
'size' => 64,
]);
$resourceUrl = $this->urlGenerator->linkToRouteAbsolute('settings.Users.usersList') . '?search=' . rawurlencode($item['user_uid']);

return new SearchResultEntry(
$thumbnailUrl,
$item['display_name'],
$this->buildSubline($item['matched_fields']),
$resourceUrl,
'icon-user',
true,
);
}

/**
* @param list<array{field_key: string, field_label: string, value: string}> $matchedFields
*/
private function buildSubline(array $matchedFields): string {
$parts = array_map(
static fn (array $match): string => sprintf('%s: %s', $match['field_label'], $match['value']),
$matchedFields,
);

return implode(' • ', $parts);
}
}
1 change: 1 addition & 0 deletions src/views/AdminSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
<div class="profile-fields-admin__section-heading">
<h4>Behavior</h4>
<p>Choose how the field is stored, displayed and exposed by default.</p>
<p>Fields visible to users can appear in global search when stored values are public or visible to authenticated users. Hidden fields and private values stay searchable only for administrators.</p>
</div>

<div class="profile-fields-admin__grid">
Expand Down
Loading
Loading