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
9 changes: 7 additions & 2 deletions lib/Controller/AppointmentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use OCA\Attendance\Service\CheckinService;
use OCA\Attendance\Service\ConfigService;
use OCA\Attendance\Service\DeadlineUpdate;
use OCA\Attendance\Service\DirectorySearchService;
use OCA\Attendance\Service\ExportOptions;
use OCA\Attendance\Service\ExportService;
use OCA\Attendance\Service\GuestService;
Expand All @@ -33,6 +34,7 @@

/**
* @psalm-import-type AttendanceAppointmentPage from \OCA\Attendance\ResponseDefinitions
* @psalm-import-type AttendanceDirectoryEntry from \OCA\Attendance\ResponseDefinitions
* @psalm-import-type AttendanceExportSeries from \OCA\Attendance\ResponseDefinitions
*/
class AppointmentController extends Controller {
Expand All @@ -51,6 +53,7 @@ class AppointmentController extends Controller {
private GuestService $guestService;
private BookingService $bookingService;
private TalkRoomService $talkRoomService;
private DirectorySearchService $directorySearchService;

public function __construct(
string $appName,
Expand All @@ -70,6 +73,7 @@ public function __construct(
GuestService $guestService,
BookingService $bookingService,
TalkRoomService $talkRoomService,
DirectorySearchService $directorySearchService,
) {
parent::__construct($appName, $request);
$this->appointmentService = $appointmentService;
Expand All @@ -87,6 +91,7 @@ public function __construct(
$this->guestService = $guestService;
$this->bookingService = $bookingService;
$this->talkRoomService = $talkRoomService;
$this->directorySearchService = $directorySearchService;
}

/**
Expand Down Expand Up @@ -1460,7 +1465,7 @@ public function exportSeries(): DataResponse {
* Search for users, groups, and teams
*
* @param string $search Search query
* @return DataResponse<Http::STATUS_OK, list<array{id: string, label: string, type: string, icon: string, isGuest: bool}>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, array{error: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array{error: string}, array{}>
* @return DataResponse<Http::STATUS_OK, list<AttendanceDirectoryEntry>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, array{error: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array{error: string}, array{}>
*/
#[NoAdminRequired]
#[NoCSRFRequired]
Expand All @@ -1481,7 +1486,7 @@ public function searchUsersGroupsTeams(string $search = ''): DataResponse {
}

try {
$results = $this->appointmentService->searchUsersGroupsTeams($search);
$results = $this->directorySearchService->search($search, $user->getUID());
return new DataResponse($results);
} catch (\Exception $e) {
return new DataResponse(['error' => $e->getMessage()], 400);
Expand Down
1 change: 1 addition & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@
* isAllDay?: bool,
* }
* @psalm-type AttendanceAudienceRef = array{id: string, label: string, type: string, isGuest?: bool}
* @psalm-type AttendanceDirectoryEntry = array{id: string, label: string, type: string, icon: string, isGuest: bool}
* @psalm-type AttendanceCategoryTemplate = array{
* name: string,
* description: string,
Expand Down
121 changes: 0 additions & 121 deletions lib/Service/AppointmentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,9 @@
use OCA\Attendance\Db\AttendanceResponseMapper;
use OCA\Attendance\Db\CategoryMapper;
use OCA\Attendance\Db\DatetimeFormatTrait;
use OCP\App\IAppManager;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\Collaboration\Collaborators\ISearch as ICollaboratorSearch;
use OCP\IGroupManager;
use OCP\IUserManager;
use OCP\Share\IShare;

/**
* Core service for managing appointments and responses.
Expand Down Expand Up @@ -48,8 +45,6 @@ class AppointmentService {
private ResponseSummaryService $responseSummaryService;
private NotificationService $notificationService;
private AttachmentService $attachmentService;
private ICollaboratorSearch $collaboratorSearch;
private IAppManager $appManager;
private GuestService $guestService;
private AuditEventService $auditEventService;
private BookingService $bookingService;
Expand All @@ -74,8 +69,6 @@ public function __construct(
ResponseSummaryService $responseSummaryService,
NotificationService $notificationService,
AttachmentService $attachmentService,
ICollaboratorSearch $collaboratorSearch,
IAppManager $appManager,
GuestService $guestService,
AuditEventService $auditEventService,
BookingService $bookingService,
Expand All @@ -99,8 +92,6 @@ public function __construct(
$this->responseSummaryService = $responseSummaryService;
$this->notificationService = $notificationService;
$this->attachmentService = $attachmentService;
$this->collaboratorSearch = $collaboratorSearch;
$this->appManager = $appManager;
$this->guestService = $guestService;
$this->auditEventService = $auditEventService;
$this->bookingService = $bookingService;
Expand Down Expand Up @@ -2120,118 +2111,6 @@ public function getUpcomingAppointmentsForWidget(string $userId, int $limit = 5)
return $result;
}

/**
* Search for users, groups, and teams (circles).
* Uses the ICollaboratorSearch interface to include all registered share types.
*/
public function searchUsersGroupsTeams(string $search = ''): array {
$shareTypes = [
IShare::TYPE_USER,
IShare::TYPE_GROUP,
];

// Add circles/teams if the app is enabled
if ($this->appManager->isEnabledForUser('circles')) {
$shareTypes[] = IShare::TYPE_CIRCLE;
}

[$searchResult, $hasMore] = $this->collaboratorSearch->search(
$search,
$shareTypes,
false, // lookup
// Bound the page so an empty/very-short query on a large directory
// can't fan out to every user/group/team. 200 leaves plenty of
// headroom for a typical org while keeping the response bounded.
200,
0
);

return $this->formatCollaboratorResults($searchResult);
}

/**
* Format collaborator search results into a consistent format.
*/
private function formatCollaboratorResults($searchResult): array {
$results = [];
// Handle both ISearchResult object and array returns
$resultData = is_array($searchResult) ? $searchResult : $searchResult->asArray();

// Process exact matches and regular matches
foreach (['exact', 'users', 'groups', 'circles'] as $category) {
$items = [];

if ($category === 'exact') {
// Exact matches are nested by type
$exactMatches = $resultData['exact'] ?? [];
foreach (['users', 'groups', 'circles'] as $subCategory) {
if (isset($exactMatches[$subCategory])) {
$items = array_merge($items, $exactMatches[$subCategory]);
}
}
} else {
$items = $resultData[$category] ?? [];
}

foreach ($items as $item) {
$shareType = $item['value']['shareType'] ?? null;
$type = $this->mapShareTypeToString($shareType);

if ($type === null) {
continue;
}

$id = $item['value']['shareWith'] ?? $item['shareWith'] ?? '';
$results[] = [
'id' => $id,
'label' => $item['label'] ?? '',
'type' => $type,
'icon' => $this->getIconForType($type),
'isGuest' => $type === 'user' && $id !== ''
? $this->guestService->isGuestUser((string)$id)
: false,
];
}
}

// Remove duplicates based on id + type
$seen = [];
$uniqueResults = [];
foreach ($results as $result) {
$key = $result['type'] . ':' . $result['id'];
if (!isset($seen[$key])) {
$seen[$key] = true;
$uniqueResults[] = $result;
}
}

return $uniqueResults;
}

/**
* Map share type integer to string type.
*/
private function mapShareTypeToString(?int $shareType): ?string {
return match ($shareType) {
IShare::TYPE_USER => 'user',
IShare::TYPE_GROUP => 'group',
IShare::TYPE_CIRCLE => 'team',
default => null,
};
}

/**
* Get icon class for a given type.
*/
private function getIconForType(string $type): string {
return match ($type) {
'user' => 'icon-user',
'group' => 'icon-group',
'team' => 'icon-team',
default => 'icon-user',
};
}

/**
* Get user IDs of users who have not responded to an appointment.
*
Expand Down
164 changes: 164 additions & 0 deletions lib/Service/DirectorySearchService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
<?php

declare(strict_types=1);

namespace OCA\Attendance\Service;

use OCA\Attendance\ResponseDefinitions;
use OCP\App\IAppManager;
use OCP\Collaboration\Collaborators\ISearch as ICollaboratorSearch;
use OCP\IUserManager;
use OCP\Share\IShare;

/**
* The users/groups/teams directory behind the audience and organizer
* pickers, built on Nextcloud's sharee search so its enumeration rules apply.
*
* @psalm-import-type AttendanceDirectoryEntry from ResponseDefinitions
*/
class DirectorySearchService {
public function __construct(
private ICollaboratorSearch $collaboratorSearch,
private IAppManager $appManager,
private IUserManager $userManager,
private GuestService $guestService,
) {
}

/**
* @return list<AttendanceDirectoryEntry>
*/
public function search(string $query, string $currentUserId): array {
$shareTypes = [
IShare::TYPE_USER,
IShare::TYPE_GROUP,
];

if ($this->appManager->isEnabledForUser('circles')) {
$shareTypes[] = IShare::TYPE_CIRCLE;
}

[$searchResult] = $this->collaboratorSearch->search(
$query,
$shareTypes,
false, // lookup
// Bound the page so an empty/very-short query on a large directory
// can't fan out to every user/group/team. 200 leaves plenty of
// headroom for a typical org while keeping the response bounded.
200,
0
);

// ISearch::search() is untyped; its first element is the result page as an array.
$page = is_array($searchResult) ? $searchResult : [];
return $this->withCurrentUser($this->formatCollaboratorResults($page), $query, $currentUserId);
}

/**
* Nobody shares with themselves, so the sharee search drops the searching
* user — but an organizer may well attend their own appointment.
*
* @param list<AttendanceDirectoryEntry> $results
* @return list<AttendanceDirectoryEntry>
*/
private function withCurrentUser(array $results, string $query, string $currentUserId): array {
$user = $this->userManager->get($currentUserId);
if ($user === null) {
return $results;
}
$needle = mb_strtolower(trim($query));
$hits = array_filter(
array_map(mb_strtolower(...), [$user->getUID(), $user->getDisplayName(), $user->getEMailAddress() ?? '']),
static fn (string $name): bool => $name !== '' && str_contains($name, $needle),
);
if ($hits === []) {
return $results;
}
foreach ($results as $result) {
if ($result['type'] === 'user' && $result['id'] === $user->getUID()) {
return $results;
}
}

$self = $this->directoryEntry('user', $user->getUID(), $user->getDisplayName());
// An exact hit leads, like the sharee search's own exact matches do.
return in_array($needle, $hits, true) ? [$self, ...$results] : [...$results, $self];
}

/**
* @return AttendanceDirectoryEntry
*/
private function directoryEntry(string $type, string $id, string $label): array {
return [
'id' => $id,
'label' => $label,
'type' => $type,
'icon' => $this->getIconForType($type),
'isGuest' => $type === 'user' && $id !== '' && $this->guestService->isGuestUser($id),
];
}

/**
* @return list<AttendanceDirectoryEntry>
*/
private function formatCollaboratorResults(array $searchResult): array {
$results = [];
foreach (['exact', 'users', 'groups', 'circles'] as $category) {
$items = [];

if ($category === 'exact') {
// Exact matches are nested by type
$exactMatches = $searchResult['exact'] ?? [];
foreach (['users', 'groups', 'circles'] as $subCategory) {
if (isset($exactMatches[$subCategory])) {
$items = array_merge($items, $exactMatches[$subCategory]);
}
}
} else {
$items = $searchResult[$category] ?? [];
}

foreach ($items as $item) {
$shareType = $item['value']['shareType'] ?? null;
$type = $this->mapShareTypeToString($shareType);

if ($type === null) {
continue;
}

$id = (string)($item['value']['shareWith'] ?? $item['shareWith'] ?? '');
$results[] = $this->directoryEntry($type, $id, (string)($item['label'] ?? ''));
}
}

$seen = [];
$uniqueResults = [];
foreach ($results as $result) {
$key = $result['type'] . ':' . $result['id'];
if (!isset($seen[$key])) {
$seen[$key] = true;
$uniqueResults[] = $result;
}
}

return $uniqueResults;
}

private function mapShareTypeToString(?int $shareType): ?string {
return match ($shareType) {
IShare::TYPE_USER => 'user',
IShare::TYPE_GROUP => 'group',
IShare::TYPE_CIRCLE => 'team',
default => null,
};
}

private function getIconForType(string $type): string {
return match ($type) {
'user' => 'icon-user',
'group' => 'icon-group',
'team' => 'icon-team',
default => 'icon-user',
};
}
}
Loading
Loading