From 683c657c0cf9d5c7e2decdc4f730f5f5c7ede58a Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 2 Sep 2026 00:55:35 +0200 Subject: [PATCH] feat: implement recipient api for unified sharing Signed-off-by: Robin Appelman --- .../lib/Controller/ApiV1Controller.php | 47 ++++++++++ lib/private/Sharing/SharingBackend.php | 93 +++++++++++++++++++ lib/private/Sharing/SharingManager.php | 11 +++ lib/unstable/Sharing/ISharingBackend.php | 20 ++++ lib/unstable/Sharing/ISharingManager.php | 19 ++++ 5 files changed, 190 insertions(+) diff --git a/apps/sharing/lib/Controller/ApiV1Controller.php b/apps/sharing/lib/Controller/ApiV1Controller.php index 4af3e0b208171..02034a543ad44 100644 --- a/apps/sharing/lib/Controller/ApiV1Controller.php +++ b/apps/sharing/lib/Controller/ApiV1Controller.php @@ -26,6 +26,7 @@ use NCU\Sharing\Share; use NCU\Sharing\ShareAccessContext; use NCU\Sharing\ShareState; +use NCU\Sharing\ShareUser; use NCU\Sharing\ShareUserStatus; use NCU\Sharing\Source\IShareSourceType; use NCU\Sharing\Source\ShareSource; @@ -125,6 +126,52 @@ public function searchRecipients(?array $filterRecipientTypeClasses, string $que } } + /** + * Get recommended recipients for the current user, based on share frequency. + * + * @param ?list> $filterRecipientTypeClasses Type classes of recipients to filter by + * @param int<1, 100> $limit The maximum number of participants + * @param ?non-empty-string $id If provided, recipients that are already part of the share will not be returned. + * @param ?non-empty-string $afterRecipientClass If all `after` values are provided, return recipients that come after the specified recipient. + * @param ?non-empty-string $afterRecipientInstance If all `after` values are provided, return recipients that come after the specified recipient. + * @param ?non-empty-string $afterRecipientValue If all `after` values are provided, return recipients that come after the specified recipient. + * @return DataResponse, array{}>|DataResponse + * + * 200: Recipients returned + * 400: Invalid recipient search parameters + * 404: Share used for filtering existing recipients does not exist + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/v1/recipients/recommended')] + public function recommendRecipients( + ?array $filterRecipientTypeClasses = null, + int $limit = 5, + ?string $id = null, + ?string $afterRecipientClass = null, + ?string $afterRecipientInstance = null, + ?string $afterRecipientValue = null, + ): DataResponse { + $afterRecipient = null; + if (!is_null($afterRecipientClass) || !is_null($afterRecipientInstance) || !is_null($afterRecipientValue)) { + if (is_null($afterRecipientClass)) { + return new DataResponse('either all `after` values need to be null, or none of them', Http::STATUS_BAD_REQUEST); + } + if (is_null($afterRecipientInstance)) { + return new DataResponse('either all `after` values need to be null, or none of them', Http::STATUS_BAD_REQUEST); + } + if (is_null($afterRecipientValue)) { + return new DataResponse('either all `after` values need to be null, or none of them', Http::STATUS_BAD_REQUEST); + } + $afterRecipient = new ShareRecipient( + $afterRecipientClass, + $afterRecipientInstance, + $afterRecipientValue + ); + } + $user = new ShareUser($this->accessContext->currentUser->getUID(), null); + $this->manager->getRecipientsForUser($user, $filterRecipientTypeClasses, $id, $limit, $afterRecipient); + } + /** * Generate a new secret. * diff --git a/lib/private/Sharing/SharingBackend.php b/lib/private/Sharing/SharingBackend.php index bc62456b12a2d..014436c3a3856 100644 --- a/lib/private/Sharing/SharingBackend.php +++ b/lib/private/Sharing/SharingBackend.php @@ -1408,6 +1408,99 @@ public function createSharePermissionDefaultValue(Share $share, string $permissi ); } + #[\Override] + public function getRecipientsForUser( + ShareUser $user, + ?array $filterRecipientTypeClasses = null, + ?string $notInShare = null, + ?int $count = 5, + ?ShareRecipient $after = null, + ): array { + $query = $this->connection->getTypedQueryBuilder(); + $query->select('recipient_class_id', 'recipient_value', 'recipient_instance', 'initiator_user_id', 'initiator_user_id') + ->selectAlias('count(*)', 'count') + ->from('sharing_share_recipients', 'r') + ->innerJoin('r', 'sharing_share', 's', $query->expr()->eq('r.share_id', 's.id')) + ->where( + $query->expr()->orX([ + $query->expr()->andX([ + $query->expr()->eq('s.owner_user_id', $query->createNamedParameter($user->userId)), + $query->expr()->eq('s.owner_instance', $query->createNamedParameter($user->instance)), + ]), + $query->expr()->andX([ + $query->expr()->eq('r.initiator_user_id', $query->createNamedParameter($user->userId)), + $query->expr()->eq('r.initiator_instance', $query->createNamedParameter($user->instance)), + ]), + ]) + ) + ->groupBy('r.*') + ->orderBy('count', \SortDirection::Descending) + // sort by recipient to get a stable output, and allow "after" to be deterministic + ->addOrderBy( + 'recipient_instance', \SortDirection::Ascending + ) + ->addOrderBy( + 'recipient_value', \SortDirection::Ascending + ) + ->addOrderBy('recipient_class_id', \SortDirection::Ascending); + + if ($filterRecipientTypeClasses) { + $query = $query->andWhere( + $query->expr()->in('recipient_class_id', $query->createNamedParameter($filterRecipientTypeClasses, IQueryBuilder::PARAM_STR_ARRAY)) + ); + } + + if ($notInShare) { + $fullRecipientId = $query->func()->concat('r.recipient_class_id', 'recipient_instance', 'recipient_value'); + + $subQuery = $this->connection->getTypedQueryBuilder(); + $subQuery->selectAlias($fullRecipientId, 'recipient') + ->from('sharing_share_recipients') + ->where($query->expr()->eq('share_id', $query->createNamedParameter($notInShare))); + + $query = $query->having( + $query->expr()->notIn( + $fullRecipientId, + $query->createFunction('(' . $subQuery->getSQL() . ')') + ) + ); + } + + if ($count) { + $query->setMaxResults($count); + } + + if ($after) { + $query = $query->andWhere( + $query->expr()->orX([ + $query->expr()->andX([ + $query->expr()->eq('s.recipient_instance', $query->createNamedParameter($after->instance)), + $query->expr()->eq('s.recipient_value', $query->createNamedParameter($user->userId)), + $query->expr()->gt('s.recipient_class_id', $query->createNamedParameter($user->userId)), + ]), + $query->expr()->andX([ + $query->expr()->eq('s.recipient_instance', $query->createNamedParameter($after->instance)), + $query->expr()->gt('s.recipient_value', $query->createNamedParameter($user->userId)), + ]), + $query->expr()->gt('s.recipient_instance', $query->createNamedParameter($after->instance)), + ]) + ); + } + + $rows = $query->executeQuery()->fetchAll(); + + return array_map(fn (array $row) => new ShareRecipient( + $row['recipient_class_id'], + $row['recipient_value'], + $row['recipient_instance'], + null, + new ShareUser( + $row['initiator_user_id'], + $row['initiator_instance'], + ) + ), $rows); + } + private static function parseTimestamp(string $timestampMs): \DateTimeImmutable { if (method_exists(\DateTimeImmutable::class, 'createFromTimestamp')) { // with php 8.3 the method doesn't exist and psalm doesn't know the return type diff --git a/lib/private/Sharing/SharingManager.php b/lib/private/Sharing/SharingManager.php index add248bcb934e..e84ed09709af9 100644 --- a/lib/private/Sharing/SharingManager.php +++ b/lib/private/Sharing/SharingManager.php @@ -769,6 +769,17 @@ public function getShares( return $this->backend->getShares($accessContext, $filterSourceTypeClass, $filterSourceTypeValue, $filterState, $filterUserStatus, $lastShareID, $limit); } + #[\Override] + public function getRecipientsForUser( + ShareUser $user, + ?array $filterRecipientTypeClasses = null, + ?string $notInShare = null, + ?int $count = 5, + ?ShareRecipient $after = null, + ): array { + return $this->backend->getRecipientsForUser($user, $filterRecipientTypeClasses, $count, $after); + } + #[\Override] public function handle(Event $event): void { if ($event instanceof SharesDefaultSetEvent) { diff --git a/lib/unstable/Sharing/ISharingBackend.php b/lib/unstable/Sharing/ISharingBackend.php index 1d1f820886969..65fd18760f184 100644 --- a/lib/unstable/Sharing/ISharingBackend.php +++ b/lib/unstable/Sharing/ISharingBackend.php @@ -14,6 +14,7 @@ use NCU\Sharing\Permission\ISharePermissionPreset; use NCU\Sharing\Permission\SharePermission; use NCU\Sharing\Property\ShareProperty; +use NCU\Sharing\Recipient\IShareRecipientType; use NCU\Sharing\Recipient\ShareRecipient; use NCU\Sharing\Source\IShareSourceType; use NCU\Sharing\Source\ShareSource; @@ -212,4 +213,23 @@ public function setLastUpdated(array $ids, \DateTimeImmutable $lastUpdated): voi * @experimental 35.0.0 */ public function ensureDefaults(array $shares): array; + + /** + * Get a list of recipients a user has shared with, ordered by share count + * + * "shared with" includes both shares owned by the user, and reshares initiated by the user + * + * @param ?list> $filterRecipientTypeClasses + * @param null|non-empty-string $notInShare + * @param null|non-negative-int $count + * @return ShareRecipient[] + * @experimental 35.0.0 + */ + public function getRecipientsForUser( + ShareUser $user, + ?array $filterRecipientTypeClasses = null, + ?string $notInShare = null, + ?int $count = 5, + ?ShareRecipient $after = null, + ): array; } diff --git a/lib/unstable/Sharing/ISharingManager.php b/lib/unstable/Sharing/ISharingManager.php index 044cf78f0202b..9bc74d31684e8 100644 --- a/lib/unstable/Sharing/ISharingManager.php +++ b/lib/unstable/Sharing/ISharingManager.php @@ -225,4 +225,23 @@ public function getShare(ShareAccessContext $accessContext, string $id): Share; * @experimental 35.0.0 */ public function getShares(ShareAccessContext $accessContext, ?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?ShareState $filterState, ?ShareUserStatus $filterUserStatus, ?string $lastShareID, ?int $limit): array; + + /** + * Get a list of recipients a user has shared with, ordered by share count + * + * "shared with" includes both shares owned by the user, and reshares initiated by the user + * + * @param ?list> $filterRecipientTypeClasses + * @param null|non-empty-string $notInShare + * @param null|non-negative-int $count + * @return ShareRecipient[] + * @experimental 35.0.0 + */ + public function getRecipientsForUser( + ShareUser $user, + ?array $filterRecipientTypeClasses = null, + ?string $notInShare = null, + ?int $count = 5, + ?ShareRecipient $after = null, + ): array; }