diff --git a/README.md b/README.md index 79532d3d29..2e8ec4ddc0 100644 --- a/README.md +++ b/README.md @@ -155,3 +155,15 @@ like `git config --global alias.ci 'commit -s'`. Now you can commit with [dcofile]: https://github.com/nextcloud/server/blob/master/contribute/developer-certificate-of-origin [applyalicense]: https://github.com/nextcloud/server/blob/master/contribute/HowToApplyALicense.md + +## Teams navigation integration + +Deck implements `OCP\Teams\ITeamAppProvider` and declares it through +`IRegistrationContext::registerTeamAppProvider()`, without a registration event. +Team-owned boards appear as external links, +while boards personally shared to a team remain in its shared-resource list. +The provider is PHP-only: it discovers active team boards, returns external links +and a declarative creation action, and creates boards through Deck's existing +service. It returns no browser module. Upgrade Deck and Circles together; +older Deck versions no longer receive a hard-coded integration from Circles. +Focused coverage: `tests/unit/Teams/TeamsProviderTest.php`. diff --git a/appinfo/routes.php b/appinfo/routes.php index 8a2b9be5fe..1d86dfde29 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -138,6 +138,7 @@ ], 'ocs' => [ ['name' => 'board_ocs#index', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'GET'], + ['name' => 'board_ocs#listForTeam', 'url' => '/api/v{apiVersion}/boards/team/{teamId}', 'verb' => 'GET'], ['name' => 'board_ocs#read', 'url' => '/api/v{apiVersion}/board/{boardId}', 'verb' => 'GET'], ['name' => 'stack_ocs#index', 'url' => '/api/v{apiVersion}/stacks/{boardId}', 'verb' => 'GET'], ['name' => 'board_ocs#create', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'POST'], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index bd1399b7e9..0cb3adaff0 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -127,6 +127,9 @@ public function boot(IBootContext $context): void { } public function register(IRegistrationContext $context): void { + if (interface_exists(\OCP\Teams\ITeamAppProvider::class)) { + $context->registerTeamAppProvider(\OCA\Deck\Teams\TeamsProvider::class); + } if ((@include_once __DIR__ . '/../../vendor/autoload.php') === false) { throw new Exception('Cannot include autoload. Did you run install dependencies using composer?'); } diff --git a/lib/Controller/BoardOcsController.php b/lib/Controller/BoardOcsController.php index fcfe09adbf..536764c3f3 100644 --- a/lib/Controller/BoardOcsController.php +++ b/lib/Controller/BoardOcsController.php @@ -7,13 +7,16 @@ namespace OCA\Deck\Controller; +use OCA\Deck\Db\BoardMapper; use OCA\Deck\Service\BoardService; use OCA\Deck\Service\ExternalBoardService; +use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\IRequest; +use OCP\Teams\ITeamManager; use Psr\Log\LoggerInterface; class BoardOcsController extends OCSController { @@ -21,7 +24,9 @@ public function __construct( string $appName, IRequest $request, private BoardService $boardService, + private BoardMapper $boardMapper, private ExternalBoardService $externalBoardService, + private ITeamManager $teamManager, private LoggerInterface $logger, private $userId, ) { @@ -34,6 +39,21 @@ public function index(): DataResponse { return new DataResponse($internalBoards); } + #[NoAdminRequired] + public function listForTeam(string $teamId): DataResponse { + if ($this->userId === null || !$this->teamManager->isMemberOfTeam($teamId, $this->userId)) { + throw new OCSNotFoundException('Team not found'); + } + $boards = array_map(static fn ($board) => [ + 'id' => $board->getId(), + 'title' => $board->getTitle(), + 'teamId' => $board->getTeamId(), + 'archived' => $board->getArchived(), + 'deletedAt' => $board->getDeletedAt(), + ], $this->boardMapper->findAllAttachedToTeam($teamId)); + return new DataResponse($boards); + } + #[NoAdminRequired] #[PublicPage] public function read(int $boardId): DataResponse { diff --git a/lib/Teams/TeamsProvider.php b/lib/Teams/TeamsProvider.php new file mode 100644 index 0000000000..4522cc914d --- /dev/null +++ b/lib/Teams/TeamsProvider.php @@ -0,0 +1,89 @@ + */ + public function getRouteAliases(): array { + return []; + } + + public function getNavigation(array $context): TeamAppNavigation { + $boards = []; + foreach ($this->boardMapper->findAllAttachedToTeam($context['teamId']) as $board) { + if (!$board->getArchived() && !$board->getDeletedAt()) { + $boards[(string)$board->getId()] = $board; + } + } + $entries = []; + $keys = []; + foreach ($context['resources'] as $resource) { + if ($resource->getProvider()->getId() !== 'deck' || !isset($boards[$resource->getId()])) { + continue; + } + $board = $boards[$resource->getId()]; + $entries[] = new TeamAppEntry( + id: (string)$board->getId(), label: $board->getTitle(), + href: $resource->getUrl(), iconSvg: $resource->getIconSvg(), + legacyIds: ['board-' . $board->getId()], + ); + $keys[] = new TeamAppResourceKey('deck', $resource->getId()); + } + usort($entries, static fn (TeamAppEntry $left, TeamAppEntry $right): int => strnatcasecmp($left->label, $right->label)); + return new TeamAppNavigation(entries: $entries, resourceKeys: $keys, actions: $context['isAdmin'] ? [new TeamAppAction( + id: 'deck-create', label: $this->l10n->t('New Deck board'), + prompt: new TeamAppActionPrompt($this->l10n->t('Board name')), + )] : []); + } + + public function executeAction(array $context, string $actionId, array $parameters): TeamAppActionResult { + if (!$context['isAdmin'] || $actionId !== 'deck-create') { + throw new InvalidArgumentException('Unknown or unauthorized Teams action'); + } + $title = $parameters['value'] ?? null; + if (!is_string($title) || trim($title) === '') { + throw new InvalidArgumentException('Board name must not be empty'); + } + $this->boards->createForTeam(trim($title), $context['userId'], null, $context['teamId']); + return new TeamAppActionResult(); + } +} diff --git a/tests/unit/Teams/TeamsProviderTest.php b/tests/unit/Teams/TeamsProviderTest.php new file mode 100644 index 0000000000..0f5039f6ef --- /dev/null +++ b/tests/unit/Teams/TeamsProviderTest.php @@ -0,0 +1,80 @@ +createMock(BoardService::class); + $boardMapper = $this->createMock(BoardMapper::class); + $boards = []; + foreach ([[1, false, 0], [2, true, 0], [3, false, 123]] as [$id, $archived, $deleted]) { + $board = new Board(); + $board->setId($id); + $board->setTitle('Board ' . $id); + $board->setTeamId('team'); + $board->setArchived($archived); + $board->setDeletedAt($deleted); + $boards[] = $board; + } + $boardMapper->method('findAllAttachedToTeam')->with('team')->willReturn($boards); + $resources = $this->createMock(ITeamResourceProvider::class); + $resources->method('getId')->willReturn('deck'); + $provider = new TeamsProvider($service, $boardMapper, $this->createMock(IL10N::class)); + $result = $provider->getNavigation(['teamId' => 'team', 'isAdmin' => false, 'resources' => array_map(static fn (int $id): TeamResource => new TeamResource($resources, (string)$id, 'Board', '/deck/' . $id), [1, 2, 3])]); + $this->assertSame(['1'], array_column($result->entries, 'id')); + $this->assertSame('/deck/1', $result->entries[0]->href); + $this->assertEquals([new TeamAppResourceKey('deck', '1')], $result->resourceKeys); + $this->assertSame([], $result->actions); + } + + public function testCreationUsesTheAuthorizedTeamAndUser(): void { + $service = $this->createMock(BoardService::class); + $service->expects($this->once())->method('createForTeam')->with('New', 'member', null, 'team')->willReturn(new Board()); + $provider = new TeamsProvider($service, $this->createMock(BoardMapper::class), $this->createMock(IL10N::class)); + $this->assertEquals(new TeamAppActionResult(), $provider->executeAction(['isAdmin' => true, 'userId' => 'member', 'teamId' => 'team'], 'deck-create', ['value' => ' New '])); + } + + public function testNonadminCannotCreateBoards(): void { + $service = $this->createMock(BoardService::class); + $service->expects($this->never())->method('createForTeam'); + $this->expectException(\InvalidArgumentException::class); + (new TeamsProvider($service, $this->createMock(BoardMapper::class), $this->createMock(IL10N::class)))->executeAction(['isAdmin' => false], 'deck-create', ['value' => 'New']); + } + + public function testCreationRejectsEmptyNames(): void { + $service = $this->createMock(BoardService::class); + $service->expects($this->never())->method('createForTeam'); + $this->expectException(\InvalidArgumentException::class); + (new TeamsProvider($service, $this->createMock(BoardMapper::class), $this->createMock(IL10N::class)))->executeAction(['isAdmin' => true], 'deck-create', ['value' => ' ']); + } + + public function testImplementsCoreProviderAndLoadsItsModule(): void { + if (!interface_exists(ITeamAppProvider::class)) { + $this->markTestSkipped('Requires the core Teams app provider API'); + } + $provider = new TeamsProvider($this->createMock(BoardService::class), $this->createMock(BoardMapper::class), $this->createMock(IL10N::class)); + $this->assertInstanceOf(ITeamAppProvider::class, $provider); + $this->assertSame('deck', $provider->getId()); + $this->assertNull($provider->load()); + $this->assertSame(20, $provider->getOrder()); + } +}