From 76c94589736976804083ec7786179bc140eed237 Mon Sep 17 00:00:00 2001 From: Fin Clausen Date: Fri, 21 Aug 2026 13:41:55 +0200 Subject: [PATCH 1/3] feat(teamspaces): support linking existing group folders for team folder table Co-authored-by: Copilot GPT-5.6 Terra Signed-off-by: Fin Clausen --- lib/Controller/FolderController.php | 38 +++++++ lib/Folder/FolderManager.php | 40 +++++++ openapi.json | 129 ++++++++++++++++++++++ src/settings/Api.ts | 17 +++ src/types/openapi/openapi.ts | 71 ++++++++++++ tests/Folder/FolderManagerTest.php | 31 ++++++ tests/TeamSpace/TeamSpaceProviderTest.php | 10 -- 7 files changed, 326 insertions(+), 10 deletions(-) diff --git a/lib/Controller/FolderController.php b/lib/Controller/FolderController.php index a2634b0d3..b2eaabbc2 100644 --- a/lib/Controller/FolderController.php +++ b/lib/Controller/FolderController.php @@ -642,4 +642,42 @@ public function aclMappingSearch(int $id, string $search = ''): DataResponse { public function getFoldersCount(): DataResponse { return new DataResponse(['count' => $this->manager->countAllFolders()]); } + + /** + * Gets all Groupfolders assigned to a circle with quota and size information + * + * @param string $circleId The circle single id to look up folders for. + * @return DataResponse, array{}> + * + * 200: Groupfolders for circle returned + */ + #[NoAdminRequired] + #[FrontpageRoute(verb: 'GET', url: '/circles/{circleId}/folders', requirements: ['circleId' => '.+'])] + public function getFoldersForCircle(string $circleId): DataResponse { + $storageId = $this->getRootFolderStorageId(); + if ($storageId === null) { + throw new OCSNotFoundException(); + } + + $folders = []; + foreach ($this->manager->getFoldersWithSizeForCircle($circleId) as $folder) { + $folders[(string)$folder->id] = $this->formatFolder($folder); + } + + if ($this->delegationService->hasOnlyApiAccess()) { + $folders = $this->foldersFilter->getForApiUser($folders); + } + + if (!$this->delegationService->hasApiAccess()) { + $folders = array_values(array_filter(array_map($this->filterNonAdminFolder(...), $folders))); + } + + return new DataResponse(array_values(array_map(static fn (array $folder): array => [ + 'id' => $folder['id'], + 'mount_point' => $folder['mount_point'], + 'quota' => $folder['quota'], + 'size' => (int)$folder['size'], + 'is_team_space' => $folder['team_circle_id'] !== null, + ], $folders))); + } } diff --git a/lib/Folder/FolderManager.php b/lib/Folder/FolderManager.php index 46853da25..8c0924ee1 100644 --- a/lib/Folder/FolderManager.php +++ b/lib/Folder/FolderManager.php @@ -830,6 +830,46 @@ public function getFoldersForCircle(string $circleId): array { return array_map($this->rowToFolder(...), $rows); } + /** + * Return all group folders directly assigned to or owned by a circle, + * including their current size from the file cache. + * + * @return list + * @throws Exception + */ + public function getFoldersWithSizeForCircle(string $circleId): array { + if ($circleId === '') { + throw new \InvalidArgumentException('circleId cannot be empty'); + } + + $query = $this->selectWithFileCache(); + $query->leftJoin('f', 'group_folders_groups', 'g', $query->expr()->eq('f.folder_id', 'g.folder_id')) + ->where($query->expr()->orX( + $query->expr()->eq('g.circle_id', $query->createNamedParameter($circleId)), + $query->expr()->eq('f.team_circle_id', $query->createNamedParameter($circleId)), + )); + + /** @var list $rows */ + $rows = $query->executeQuery()->fetchAll(); + + $folderIds = array_map(static fn (array $row): int => (int)$row['folder_id'], $rows); + $applicableMap = $this->getAllApplicable($folderIds); + $folderMappings = $this->getAllFolderMappings($folderIds); + + return array_map(function (array $row) use ($applicableMap, $folderMappings): FolderWithMappingsAndCache { + $folder = $this->rowToFolder($row); + $id = $folder->id; + return FolderWithMappingsAndCache::fromFolderWithMapping( + FolderDefinitionWithMappings::fromFolder( + $folder, + $applicableMap[$id] ?? [], + $this->getManageAcl($folderMappings[$id] ?? []), + ), + Cache::cacheEntryFromData($row, $this->mimeTypeLoader), + ); + }, $rows); + } + /** * @param list $paths * @return list diff --git a/openapi.json b/openapi.json index 2510c8db4..d0d2c580f 100644 --- a/openapi.json +++ b/openapi.json @@ -2833,6 +2833,135 @@ } } } + }, + "/index.php/apps/groupfolders/circles/{circleId}/folders": { + "get": { + "operationId": "folder-get-folders-for-circle", + "summary": "Gets all Groupfolders assigned to a circle with quota and size information", + "tags": [ + "folder" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "circleId", + "in": "path", + "description": "The circle single id to look up folders for.", + "required": true, + "schema": { + "type": "string", + "pattern": "^.+$" + } + }, + { + "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": "Groupfolders for circle returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "mount_point", + "quota", + "size", + "is_team_space" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "mount_point": { + "type": "string" + }, + "quota": { + "type": "integer", + "format": "int64" + }, + "size": { + "type": "integer", + "format": "int64" + }, + "is_team_space": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } } }, "tags": [] diff --git a/src/settings/Api.ts b/src/settings/Api.ts index 6c300edec..d0d2f90be 100644 --- a/src/settings/Api.ts +++ b/src/settings/Api.ts @@ -29,6 +29,23 @@ export class Api { return Object.values(response.data.ocs.data) } + async listFoldersForCircle(circleId: string): Promise> { + const response = await axios.get>>(this.getUrl(`circles/${circleId}/folders`)) + return response.data.ocs.data + } + // Returns all NC groups async listGroups(): Promise { const response = await axios.get>(this.getUrl('delegation/groups')) diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 75cfe740d..aaf6a39dc 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -259,6 +259,23 @@ export type paths = { patch?: never; trace?: never; }; + "/index.php/apps/groupfolders/circles/{circleId}/folders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Gets all Groupfolders assigned to a circle with quota and size information */ + get: operations["folder-get-folders-for-circle"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; }; export type webhooks = Record; export type components = { @@ -1479,4 +1496,58 @@ export interface operations { }; }; }; + "folder-get-folders-for-circle": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + /** @description The circle single id to look up folders for. */ + circleId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Groupfolders for circle returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** Format: int64 */ + id: number; + mount_point: string; + /** Format: int64 */ + quota: number; + /** Format: int64 */ + size: number; + is_team_space: boolean; + }[]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; } diff --git a/tests/Folder/FolderManagerTest.php b/tests/Folder/FolderManagerTest.php index fdb1a8941..9ac51c74c 100644 --- a/tests/Folder/FolderManagerTest.php +++ b/tests/Folder/FolderManagerTest.php @@ -808,6 +808,37 @@ public function testIsExclusivelyAssignedToCircleRejectsAdditionalMappings(): vo $this->assertFalse($this->manager->isExclusivelyAssignedToCircle($folderId, 'circle-owner')); } + public function testGetFoldersWithSizeForCircleIncludesAssignedAndOwnedFolders(): void { + $assignedFolderId = $this->manager->createFolder('circle-assigned-folder'); + $ownedFolderId = $this->manager->createFolder('circle-owned-folder'); + $otherFolderId = $this->manager->createFolder('other-circle-folder'); + $this->manager->setTeamCircleId($ownedFolderId, 'circle-owner'); + + foreach ([ + [$assignedFolderId, 'circle-owner'], + [$otherFolderId, 'other-circle'], + ] as [$folderId, $circleId]) { + $query = Server::get(IDBConnection::class)->getQueryBuilder(); + $query->insert('group_folders_groups') + ->values([ + 'folder_id' => $query->createNamedParameter($folderId, IQueryBuilder::PARAM_INT), + 'group_id' => $query->createNamedParameter(''), + 'circle_id' => $query->createNamedParameter($circleId), + 'permissions' => $query->createNamedParameter(Constants::PERMISSION_ALL), + ]); + $query->executeStatement(); + } + + $folders = $this->manager->getFoldersWithSizeForCircle('circle-owner'); + $folderIds = array_map(static fn (FolderDefinition $folder): int => $folder->id, $folders); + sort($folderIds); + $expectedFolderIds = [$assignedFolderId, $ownedFolderId]; + sort($expectedFolderIds); + + $this->assertSame($expectedFolderIds, $folderIds); + $this->assertNotContains($otherFolderId, $folderIds); + } + public function testTeamCircleIdIsHydratedAsNullableString(): void { $this->config->expects($this->any()) ->method('getSystemValueInt') diff --git a/tests/TeamSpace/TeamSpaceProviderTest.php b/tests/TeamSpace/TeamSpaceProviderTest.php index b02da2256..5ba149997 100644 --- a/tests/TeamSpace/TeamSpaceProviderTest.php +++ b/tests/TeamSpace/TeamSpaceProviderTest.php @@ -88,16 +88,6 @@ public function testLinkTeamFolderLinksAndReturnsFolder(): void { $this->assertSame($folder, $this->provider->linkTeamFolder('team-1', 42)); } - public function testUpdateTeamFolderQuotaDelegatesToService(): void { - $folder = new TeamFolder(42, 'Engineering'); - $this->service->expects($this->once()) - ->method('updateTeamSpaceQuota') - ->with('team-1', 1024) - ->willReturn($folder); - - $this->assertSame($folder, $this->provider->updateTeamFolderQuota('team-1', 1024)); - } - public function testIsSharedWithTeamChecksAllFoldersAssignedToTeam(): void { $this->service->expects($this->exactly(2)) ->method('getGroupFoldersForCircle') From c1219034be3bae018734d0a4a689a9fc96aaa8cd Mon Sep 17 00:00:00 2001 From: Fin Clausen Date: Tue, 25 Aug 2026 15:48:57 +0200 Subject: [PATCH 2/3] feat: add updateTeamFolderQuota method and enhance TeamFolder with quota support in admin Settings Signed-off-by: Fin Clausen --- lib/Controller/FolderController.php | 6 +++--- lib/TeamSpace/TeamSpaceService.php | 23 +++++++++++++++++++++++ openapi.json | 4 ++-- src/settings/Api.ts | 4 ++-- src/types/openapi/openapi.ts | 2 +- tests/TeamSpace/TeamSpaceProviderTest.php | 10 ++++++++++ 6 files changed, 41 insertions(+), 8 deletions(-) diff --git a/lib/Controller/FolderController.php b/lib/Controller/FolderController.php index b2eaabbc2..6ab895a03 100644 --- a/lib/Controller/FolderController.php +++ b/lib/Controller/FolderController.php @@ -647,7 +647,7 @@ public function getFoldersCount(): DataResponse { * Gets all Groupfolders assigned to a circle with quota and size information * * @param string $circleId The circle single id to look up folders for. - * @return DataResponse, array{}> + * @return DataResponse, array{}> * * 200: Groupfolders for circle returned */ @@ -661,7 +661,7 @@ public function getFoldersForCircle(string $circleId): DataResponse { $folders = []; foreach ($this->manager->getFoldersWithSizeForCircle($circleId) as $folder) { - $folders[(string)$folder->id] = $this->formatFolder($folder); + $folders['folder_' . $folder->id] = $this->formatFolder($folder); } if ($this->delegationService->hasOnlyApiAccess()) { @@ -677,7 +677,7 @@ public function getFoldersForCircle(string $circleId): DataResponse { 'mount_point' => $folder['mount_point'], 'quota' => $folder['quota'], 'size' => (int)$folder['size'], - 'is_team_space' => $folder['team_circle_id'] !== null, + 'is_team_folder' => ($folder['team_circle_id'] ?? null) !== null, ], $folders))); } } diff --git a/lib/TeamSpace/TeamSpaceService.php b/lib/TeamSpace/TeamSpaceService.php index 7f37bafbe..5ce5613bf 100644 --- a/lib/TeamSpace/TeamSpaceService.php +++ b/lib/TeamSpace/TeamSpaceService.php @@ -230,6 +230,29 @@ public function getTeamSpaceForCircle(string $circleId): ?TeamFolder { if ($folder === null) { return null; } + return new TeamFolder($folder->id, $folder->mountPoint); + } + + /** + * Update the storage quota of the team space belonging to the given team. + * + * @param string $circleId The circle single id. + * @param int $quota Quota in bytes; zero means unlimited. + * @return TeamFolder The updated folder. + * @throws \RuntimeException if no team space is linked to the team. + */ + public function updateTeamSpaceQuota(string $circleId, int $quota): TeamFolder { + $folderId = $this->folderManager->getFolderIdByTeamCircleId($circleId); + if ($folderId === null) { + throw new \RuntimeException('No team space linked to this team'); + } + + $this->folderManager->setFolderQuota($folderId, $quota); + + $folder = $this->folderManager->getFolder($folderId); + if ($folder === null) { + throw new \RuntimeException('Team space could not be found after updating quota'); + } $this->createAppDirectory($folderId); diff --git a/openapi.json b/openapi.json index d0d2c580f..b44f69af9 100644 --- a/openapi.json +++ b/openapi.json @@ -2901,7 +2901,7 @@ "mount_point", "quota", "size", - "is_team_space" + "is_team_folder" ], "properties": { "id": { @@ -2919,7 +2919,7 @@ "type": "integer", "format": "int64" }, - "is_team_space": { + "is_team_folder": { "type": "boolean" } } diff --git a/src/settings/Api.ts b/src/settings/Api.ts index d0d2f90be..a6875774a 100644 --- a/src/settings/Api.ts +++ b/src/settings/Api.ts @@ -34,14 +34,14 @@ export class Api { mount_point: string quota: number size: number - is_team_space: boolean + is_team_folder: boolean }>> { const response = await axios.get>>(this.getUrl(`circles/${circleId}/folders`)) return response.data.ocs.data } diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index aaf6a39dc..0377349fb 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1528,7 +1528,7 @@ export interface operations { quota: number; /** Format: int64 */ size: number; - is_team_space: boolean; + is_team_folder: boolean; }[]; }; }; diff --git a/tests/TeamSpace/TeamSpaceProviderTest.php b/tests/TeamSpace/TeamSpaceProviderTest.php index 5ba149997..b02da2256 100644 --- a/tests/TeamSpace/TeamSpaceProviderTest.php +++ b/tests/TeamSpace/TeamSpaceProviderTest.php @@ -88,6 +88,16 @@ public function testLinkTeamFolderLinksAndReturnsFolder(): void { $this->assertSame($folder, $this->provider->linkTeamFolder('team-1', 42)); } + public function testUpdateTeamFolderQuotaDelegatesToService(): void { + $folder = new TeamFolder(42, 'Engineering'); + $this->service->expects($this->once()) + ->method('updateTeamSpaceQuota') + ->with('team-1', 1024) + ->willReturn($folder); + + $this->assertSame($folder, $this->provider->updateTeamFolderQuota('team-1', 1024)); + } + public function testIsSharedWithTeamChecksAllFoldersAssignedToTeam(): void { $this->service->expects($this->exactly(2)) ->method('getGroupFoldersForCircle') From cc9334dc41dbeeab20b90ab12ba4b05e80e80e2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Trill=C3=B2?= Date: Thu, 10 Sep 2026 08:50:49 +0200 Subject: [PATCH 3/3] Update lib/Controller/FolderController.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cristian Scheid <74515775+cristianscheid@users.noreply.github.com> Signed-off-by: Tomasz Trillò --- lib/Controller/FolderController.php | 2 +- lib/TeamSpace/TeamSpaceService.php | 23 ----------------------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/lib/Controller/FolderController.php b/lib/Controller/FolderController.php index 6ab895a03..1f0195742 100644 --- a/lib/Controller/FolderController.php +++ b/lib/Controller/FolderController.php @@ -661,7 +661,7 @@ public function getFoldersForCircle(string $circleId): DataResponse { $folders = []; foreach ($this->manager->getFoldersWithSizeForCircle($circleId) as $folder) { - $folders['folder_' . $folder->id] = $this->formatFolder($folder); + $folders[$folder->id] = $this->formatFolder($folder); } if ($this->delegationService->hasOnlyApiAccess()) { diff --git a/lib/TeamSpace/TeamSpaceService.php b/lib/TeamSpace/TeamSpaceService.php index 5ce5613bf..7f37bafbe 100644 --- a/lib/TeamSpace/TeamSpaceService.php +++ b/lib/TeamSpace/TeamSpaceService.php @@ -230,29 +230,6 @@ public function getTeamSpaceForCircle(string $circleId): ?TeamFolder { if ($folder === null) { return null; } - return new TeamFolder($folder->id, $folder->mountPoint); - } - - /** - * Update the storage quota of the team space belonging to the given team. - * - * @param string $circleId The circle single id. - * @param int $quota Quota in bytes; zero means unlimited. - * @return TeamFolder The updated folder. - * @throws \RuntimeException if no team space is linked to the team. - */ - public function updateTeamSpaceQuota(string $circleId, int $quota): TeamFolder { - $folderId = $this->folderManager->getFolderIdByTeamCircleId($circleId); - if ($folderId === null) { - throw new \RuntimeException('No team space linked to this team'); - } - - $this->folderManager->setFolderQuota($folderId, $quota); - - $folder = $this->folderManager->getFolder($folderId); - if ($folder === null) { - throw new \RuntimeException('Team space could not be found after updating quota'); - } $this->createAppDirectory($folderId);