Skip to content
Draft
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
1 change: 1 addition & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
3 changes: 3 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@

#[\Override]
public function register(IRegistrationContext $context): void {
if (interface_exists(\OCP\Teams\ITeamAppProvider::class)) {
$context->registerTeamAppProvider(\OCA\Deck\Teams\TeamsProvider::class);

Check failure on line 133 in lib/AppInfo/Application.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

MissingDependency

lib/AppInfo/Application.php:133:38: MissingDependency: OCA\Deck\Teams\TeamsProvider depends on class or interface ocp\teams\iteamappprovider that does not exist (see https://psalm.dev/157)

Check failure on line 133 in lib/AppInfo/Application.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedInterfaceMethod

lib/AppInfo/Application.php:133:14: UndefinedInterfaceMethod: Method OCP\AppFramework\Bootstrap\IRegistrationContext::registerTeamAppProvider does not exist (see https://psalm.dev/181)
}
if ((@include_once __DIR__ . '/../../vendor/autoload.php') === false) {
throw new Exception('Cannot include autoload. Did you run install dependencies using composer?');
}
Expand Down
20 changes: 20 additions & 0 deletions lib/Controller/BoardOcsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,26 @@

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 {
public function __construct(
string $appName,
IRequest $request,
private BoardService $boardService,
private BoardMapper $boardMapper,
private ExternalBoardService $externalBoardService,
private ITeamManager $teamManager,
private LoggerInterface $logger,
private $userId,
) {
Expand All @@ -34,6 +39,21 @@
return new DataResponse($internalBoards);
}

#[NoAdminRequired]
public function listForTeam(string $teamId): DataResponse {
if ($this->userId === null || !$this->teamManager->isMemberOfTeam($teamId, $this->userId)) {

Check failure on line 44 in lib/Controller/BoardOcsController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedInterfaceMethod

lib/Controller/BoardOcsController.php:44:54: UndefinedInterfaceMethod: Method OCP\Teams\ITeamManager::isMemberOfTeam does not exist (see https://psalm.dev/181)
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 {
Expand Down
83 changes: 83 additions & 0 deletions lib/Teams/TeamsProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

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

namespace OCA\Deck\Teams;

use InvalidArgumentException;
use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Service\BoardService;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Teams\ITeamAppProvider;
use OCP\Teams\ITeamManager;
use OCP\Teams\TeamAppNavigationAction;
use OCP\Teams\TeamAppNavigationEntry;
use OCP\Teams\TeamAppNavigation;

class TeamsProvider implements ITeamAppProvider {

Check failure on line 23 in lib/Teams/TeamsProvider.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedClass

lib/Teams/TeamsProvider.php:23:32: UndefinedClass: Class, interface or enum named OCP\Teams\ITeamAppProvider does not exist (see https://psalm.dev/019)
public function __construct(
private BoardService $boards,
private BoardMapper $boardMapper,
private ITeamManager $teamManager,
private IL10N $l10n,
private IURLGenerator $urlGenerator,
) {
}

public function getId(): string {
return 'deck';
}

public function getOrder(): int {
return 20;
}

public function getNavigation(string $teamId, string $userId, bool $isAdmin, ?\OCP\Teams\TeamFolder $folder): TeamAppNavigation {
$boards = [];
foreach ($this->boardMapper->findAllAttachedToTeam($teamId) as $board) {
if (!$board->getArchived() && !$board->getDeletedAt()) {
$boards[(string)$board->getId()] = $board;
}
}
$sharedBoardIds = array_fill_keys(array_map(
static fn ($resource): string => $resource->getId(),
array_filter($this->teamManager->getSharedWith($teamId, $userId), static fn ($resource): bool => $resource->getProvider()->getId() === 'deck'),
), true);
$entries = [];
foreach ($sharedBoardIds as $resourceId => $_) {
if (!isset($boards[$resourceId])) {
continue;
}
$board = $boards[$resourceId];
$entries[] = new TeamAppNavigationEntry(
id: (string)$board->getId(), label: $board->getTitle(),
icon: 'M16,5V18H21V5M4,18H9V5H4M10,18H15V5H10V18Z',
href: $this->urlGenerator->getAbsoluteURL('/apps/deck/#/board/' . $board->getId()),
referenceUrl: $this->urlGenerator->getAbsoluteURL('/apps/deck/#/board/' . $board->getId()),
);
}
usort($entries, static fn (TeamAppNavigationEntry $left, TeamAppNavigationEntry $right): int => strnatcasecmp($left->label, $right->label));
return new TeamAppNavigation(entries: $entries, createAction: $isAdmin ? new TeamAppNavigationAction(
id: 'deck-create', label: $this->l10n->t('New Deck board'),
prompt: $this->l10n->t('Board name'),
) : null);
}

public function addResource(string $teamId, string $teamName, string $userId, bool $isAdmin, ?\OCP\Teams\TeamFolder $folder, ?string $value): string {
if (!$isAdmin) {
throw new InvalidArgumentException('Unknown or unauthorized Teams action');
}
$title = $value;
if (!is_string($title) || trim($title) === '') {
throw new InvalidArgumentException('Board name must not be empty');
}
$board = $this->boards->createForTeam(trim($title), $userId, null, $teamId);
return (string)$board->getId();
}
}
105 changes: 105 additions & 0 deletions tests/unit/Teams/TeamsProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

declare(strict_types=1);

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

namespace OCA\Deck\Tests\Teams;

use OCA\Deck\Db\Board;
use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Service\BoardService;
use OCA\Deck\Teams\TeamsProvider;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Teams\ITeamAppProvider;
use OCP\Teams\ITeamManager;
use OCP\Teams\ITeamResourceProvider;
use OCP\Teams\TeamAppNavigationAction;
use OCP\Teams\TeamResource;
use PHPUnit\Framework\TestCase;

final class TeamsProviderTest extends TestCase {
private function teamManagerWithSharedBoardIds(array $ids): ITeamManager {
$resourceProvider = $this->createMock(ITeamResourceProvider::class);
$resourceProvider->method('getId')->willReturn('deck');
$teamManager = $this->createMock(ITeamManager::class);
$teamManager->method('getSharedWith')->with('team', 'member')->willReturn(array_map(static fn (int $id): TeamResource => new TeamResource($resourceProvider, (string)$id, 'Board', '/deck/' . $id), $ids));
return $teamManager;
}

public function testPhpDiscoversOnlyActiveTeamBoards(): void {
$service = $this->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);
$urls = $this->createMock(IURLGenerator::class);
$urls->method('getAbsoluteURL')->willReturnCallback(static fn (string $path): string => 'https://cloud.example' . $path);
$provider = new TeamsProvider($service, $boardMapper, $this->teamManagerWithSharedBoardIds([1, 2, 3]), $this->createMock(IL10N::class), $urls);
$result = $provider->getNavigation('team', 'member', false, null);
$this->assertSame(['1'], array_column($result->entries, 'id'));
$this->assertSame('M16,5V18H21V5M4,18H9V5H4M10,18H15V5H10V18Z', $result->entries[0]->icon);
$this->assertSame('https://cloud.example/apps/deck/#/board/1', $result->entries[0]->referenceUrl);
$this->assertSame($result->entries[0]->referenceUrl, $result->entries[0]->href);
$this->assertNull($result->createAction);
}

public function testAdminCreationActionDeclaresASinglePrompt(): void {
$boardMapper = $this->createMock(BoardMapper::class);
$boardMapper->method('findAllAttachedToTeam')->with('team')->willReturn([]);
$l10n = $this->createMock(IL10N::class);
$l10n->method('t')->willReturnArgument(0);
$provider = new TeamsProvider($this->createMock(BoardService::class), $boardMapper, $this->teamManagerWithSharedBoardIds([]), $l10n, $this->createMock(IURLGenerator::class));
$result = $provider->getNavigation('team', 'member', true, null);
$this->assertInstanceOf(TeamAppNavigationAction::class, $result->createAction);
$this->assertSame('deck-create', $result->createAction->id);
$this->assertSame('Board name', $result->createAction->prompt);
}

public function testCreationUsesTheAuthorizedTeamAndUser(): void {
$service = $this->createMock(BoardService::class);
$board = new Board();
$board->setId(33);
$service->expects($this->once())->method('createForTeam')->with('New', 'member', null, 'team')->willReturn($board);
$provider = new TeamsProvider($service, $this->createMock(BoardMapper::class), $this->createMock(ITeamManager::class), $this->createMock(IL10N::class), $this->createMock(IURLGenerator::class));
$this->assertSame('33', $provider->addResource('team', 'Team', 'member', true, null, ' 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(ITeamManager::class), $this->createMock(IL10N::class), $this->createMock(IURLGenerator::class)))->addResource('team', 'Team', 'member', false, null, '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(ITeamManager::class), $this->createMock(IL10N::class), $this->createMock(IURLGenerator::class)))->addResource('team', 'Team', 'member', true, null, ' ');
}

public function testImplementsCoreProviderWithoutLoadingATeamsModule(): void {
if (!interface_exists(ITeamAppProvider::class)) {
$this->markTestSkipped('Requires the core Teams app provider API');
}
$urlGenerator = $this->createMock(IURLGenerator::class);
$urlGenerator->expects($this->never())->method('linkTo');
$provider = new TeamsProvider($this->createMock(BoardService::class), $this->createMock(BoardMapper::class), $this->createMock(ITeamManager::class), $this->createMock(IL10N::class), $urlGenerator);
$this->assertInstanceOf(ITeamAppProvider::class, $provider);
$this->assertSame('deck', $provider->getId());
$this->assertSame(20, $provider->getOrder());
}
}
Loading