Skip to content
Open
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: 2 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
['name' => 'TeamFavorites#getFavorites', 'url' => '/teams/favorites', 'verb' => 'GET'],
['name' => 'TeamFavorites#setFavorite', 'url' => '/teams/{circleId}/favorite', 'verb' => 'PUT'],
['name' => 'TeamFavorites#reorder', 'url' => '/teams/favorites/order', 'verb' => 'PUT'],
['name' => 'TeamResourceFavorites#getFavorites', 'url' => '/teams/{circleId}/resource-favorites', 'verb' => 'GET'],
['name' => 'TeamResourceFavorites#setFavorites', 'url' => '/teams/{circleId}/resource-favorites', 'verb' => 'PUT'],

// Teams Dashboard widget endpoint
['name' => 'TeamsDashboard#getCompleteTeamsData', 'url' => '/teams/dashboard/widget', 'verb' => 'GET'],
Expand Down
4 changes: 4 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use OCA\Circles\Events\Files\CreatingFileShareEvent;
use OCA\Circles\Events\Files\FileShareCreatedEvent;
use OCA\Circles\Events\Files\PreparingFileShareEvent;
use OCA\Circles\Events\MembershipsRemovedEvent;
use OCA\Circles\Events\PreparingCircleMemberEvent;
use OCA\Circles\Events\RemovingCircleMemberEvent;
use OCA\Circles\Events\RequestingCircleMemberEvent;
Expand All @@ -42,6 +43,7 @@
use OCA\Circles\Listeners\NodeEventListener;
use OCA\Circles\Listeners\Notifications\RequestingMember as ListenerNotificationsRequestingMember;
use OCA\Circles\Listeners\TeamFolderLifecycleListener;
use OCA\Circles\Listeners\TeamResourceFavoritesCleanup;
use OCA\Circles\Listeners\UserCreated;
use OCA\Circles\Listeners\UserDeleted;
use OCA\Circles\Listeners\UserLoggedIn;
Expand Down Expand Up @@ -120,6 +122,8 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(UserRemovedEvent::class, GroupMemberRemoved::class);
$context->registerEventListener(CreatingCircleEvent::class, TeamFolderLifecycleListener::class);
$context->registerEventListener(DestroyingCircleEvent::class, TeamFolderLifecycleListener::class);
$context->registerEventListener(DestroyingCircleEvent::class, TeamResourceFavoritesCleanup::class);
$context->registerEventListener(MembershipsRemovedEvent::class, TeamResourceFavoritesCleanup::class);

// Local Events (for Files/Shares/Notifications management)
$context->registerEventListener(PreparingCircleMemberEvent::class, ListenerFilesPreparingMemberSendMail::class);
Expand Down
2 changes: 2 additions & 0 deletions lib/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class ConfigLexicon implements ILexicon {
public const TEAM_FOLDER_DEFAULT_QUOTA = 'team_folder_default_quota';
public const TEAM_FOLDER_GROUP_QUOTAS = 'team_folder_group_quotas';
public const DEFAULT_QUOTA = 104857600; // 104857600 bytes ~ 100 MB
// User config, suffixed with the team's single id
public const USER_RESOURCE_FAVORITES_PREFIX = 'teamsFavoriteResources_';

// OIDC
public const OIDC_ENABLED = 'oidc_enabled';
Expand Down
86 changes: 86 additions & 0 deletions lib/Controller/TeamResourceFavoritesController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Circles\Controller;

use OCA\Circles\ConfigLexicon;
use OCA\Circles\Exceptions\InsufficientPermissionException;
use OCA\Circles\Service\PermissionService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCS\OCSException;
use OCP\AppFramework\OCSController;
use OCP\Config\IUserConfig;
use OCP\IRequest;
use OCP\IUserSession;

/**
* Per-user, ordered list of favorite navigation entries of a team, stored as
* the same entry ids the team-level tab order uses (e.g. "page-42").
*/
class TeamResourceFavoritesController extends OCSController {
private const MAX_ID_LENGTH = 64;
private const MAX_ENTRIES = 100;

public function __construct(
string $appName,
IRequest $request,
private readonly IUserConfig $userConfig,
private readonly PermissionService $permissionService,
private readonly IUserSession $userSession,
) {
parent::__construct($appName, $request);
}

#[NoAdminRequired]
public function getFavorites(string $circleId): DataResponse {
$userId = $this->assertAuthenticatedUserIsMember($circleId);
$favorites = $this->userConfig->getValueArray($userId, $this->appName, ConfigLexicon::USER_RESOURCE_FAVORITES_PREFIX . $circleId, [], true);

return new DataResponse(['favorites' => $this->sanitize($favorites)]);
}

/**
* @param list<string> $favorites Entry ids, first to last
*/
#[NoAdminRequired]
public function setFavorites(string $circleId, array $favorites): DataResponse {
$userId = $this->assertAuthenticatedUserIsMember($circleId);
$favorites = $this->sanitize($favorites);
$this->userConfig->setValueArray($userId, $this->appName, ConfigLexicon::USER_RESOURCE_FAVORITES_PREFIX . $circleId, $favorites, true);

return new DataResponse(['favorites' => $favorites]);
}

/**
* @return list<string>
*/
private function sanitize(array $favorites): array {
$favorites = array_filter($favorites, static fn ($id): bool => is_string($id)
&& $id !== ''
&& strlen($id) <= self::MAX_ID_LENGTH);

return array_slice(array_values(array_unique($favorites)), 0, self::MAX_ENTRIES);
}

private function assertAuthenticatedUserIsMember(string $circleId): string {
$user = $this->userSession->getUser();
if ($user === null) {
throw new OCSException('Authentication required', Http::STATUS_UNAUTHORIZED);
}
try {
$this->permissionService->userMustBeMember($user->getUID(), $circleId);
} catch (InsufficientPermissionException $e) {
throw new OCSException($e->getMessage(), Http::STATUS_FORBIDDEN);
}

return $user->getUID();
}
}
65 changes: 65 additions & 0 deletions lib/Listeners/TeamResourceFavoritesCleanup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Circles\Listeners;

use OCA\Circles\AppInfo\Application;
use OCA\Circles\ConfigLexicon;
use OCA\Circles\Events\DestroyingCircleEvent;
use OCA\Circles\Events\MembershipsRemovedEvent;
use OCA\Circles\Model\Member;
use OCA\Circles\Service\FederatedUserService;
use OCP\Config\IUserConfig;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use Psr\Log\LoggerInterface;

/**
* Drops the favorite resources a user keeps for a team once they can no
* longer see the team, or the team is gone.
*
* @template-implements IEventListener<DestroyingCircleEvent|MembershipsRemovedEvent>
*/
class TeamResourceFavoritesCleanup implements IEventListener {
public function __construct(
private readonly IUserConfig $userConfig,
private readonly FederatedUserService $federatedUserService,
private readonly LoggerInterface $logger,
) {
}

#[\Override]
public function handle(Event $event): void {
if ($event instanceof DestroyingCircleEvent) {
$this->userConfig->deleteKey(Application::APP_ID, ConfigLexicon::USER_RESOURCE_FAVORITES_PREFIX . $event->getCircle()->getSingleId());
return;
}

if (!$event instanceof MembershipsRemovedEvent) {
return;
}

foreach ($event->getMemberships() as $membership) {
try {
$federatedUser = $this->federatedUserService->getFederatedUser($membership->getSingleId());
} catch (\Exception $e) {
$this->logger->debug('Could not resolve the member to clean up its favorite resources', ['exception' => $e]);
continue;
}
if ($federatedUser->getUserType() !== Member::TYPE_USER || !$federatedUser->isLocal()) {
continue;
}
$this->userConfig->deleteUserConfig(
$federatedUser->getUserId(),
Application::APP_ID,
ConfigLexicon::USER_RESOURCE_FAVORITES_PREFIX . $membership->getCircleId(),
);
}
}
}
41 changes: 39 additions & 2 deletions src/teams/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ vi.mock('@nextcloud/axios', () => ({
}))

vi.mock('@nextcloud/router', () => ({
generateOcsUrl: (path: string) => `/ocs/${path}`,
generateOcsUrl: (path: string, params: Record<string, string> = {}) => `/ocs/${path.replace(/\{([^}]+)\}/g, (_, key: string) => encodeURIComponent(params[key]))}`,
}))

vi.mock('../logger.ts', () => ({
Expand All @@ -32,7 +32,7 @@ vi.mock('./team-page/services/collaborationAutocompletion.js', () => ({
getSuggestions: vi.fn(),
}))

const { createTeam, fetchTeams, joinTeam, setTeamFavorite, reorderFavoriteTeams } = await import('./api.ts')
const { createTeam, fetchTeams, joinTeam, setTeamFavorite, reorderFavoriteTeams, fetchResourceFavorites, saveResourceFavorites } = await import('./api.ts')

describe('fetchTeams', () => {
it('splits active members from discoverable teams', async () => {
Expand Down Expand Up @@ -184,3 +184,40 @@ describe('favorite teams API', () => {
expect(overview.teams.map((team) => [team.id, team.isFavorite])).toEqual([['A', false]])
})
})

describe('resource favorites API', () => {
beforeEach(() => vi.resetAllMocks())

it('fetches the ordered favorites for the requested team', async () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: { ocs: { data: { favorites: ['board-2', 'page-1'] } } } })

await expect(fetchResourceFavorites('team/1')).resolves.toEqual(['board-2', 'page-1'])
expect(axios.get).toHaveBeenCalledWith('/ocs/apps/circles/teams/team%2F1/resource-favorites')
})

it('returns no favorites when none have been saved', async () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: { ocs: { data: {} } } })

await expect(fetchResourceFavorites('team-1')).resolves.toEqual([])
})

it('propagates a failed fetch', async () => {
vi.mocked(axios.get).mockRejectedValueOnce(new Error('offline'))

await expect(fetchResourceFavorites('team-1')).rejects.toThrow('offline')
})

it.each([['board-2', 'page-1'], []])('saves the favorites %j for the requested team', async (...favorites) => {
vi.mocked(axios.put).mockResolvedValueOnce({})

await saveResourceFavorites('team/1', favorites)

expect(axios.put).toHaveBeenCalledWith('/ocs/apps/circles/teams/team%2F1/resource-favorites', { favorites })
})

it('propagates a failed save', async () => {
vi.mocked(axios.put).mockRejectedValueOnce(new Error('offline'))

await expect(saveResourceFavorites('team-1', ['page-1'])).rejects.toThrow('offline')
})
})
24 changes: 24 additions & 0 deletions src/teams/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,30 @@ export async function saveTabOrder(teamId: string, order: string[]): Promise<voi
)
}

/**
* Fetch the current user's favorite navigation entries of a team.
*
* @param teamId - The team single id
* @return Entry ids, first to last
*/
export async function fetchResourceFavorites(teamId: string): Promise<string[]> {
const { data } = await axios.get<OcsResponse<{ favorites: string[] }>>(generateOcsUrl('apps/circles/teams/{circleId}/resource-favorites', { circleId: teamId }))
return data.ocs.data.favorites ?? []
}

/**
* Save the current user's favorite navigation entries of a team.
*
* @param teamId - The team single id
* @param favorites - Entry ids, first to last
*/
export async function saveResourceFavorites(teamId: string, favorites: string[]): Promise<void> {
await axios.put(
generateOcsUrl('apps/circles/teams/{circleId}/resource-favorites', { circleId: teamId }),
{ favorites },
)
}

/**
* Fetch the resources shared to a team from the core teams resource
* providers (Talk rooms, calendars, collectives, …).
Expand Down
Loading
Loading