Skip to content
Closed
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ All notable changes to this project will be documented in this file.
## 1.16.0-beta.1

### Added
- feat: add owner_type to boards for circle ownership support @jospoortvliet
- feat: resolve circle board owners in BoardMapper and include circle-owned boards in user board queries @jospoortvliet
- feat: grant full owner permissions to circle members on circle-owned boards @jospoortvliet
- feat: support transferring board ownership to a circle in BoardService @jospoortvliet
- feat: accept newOwnerType in the transfer-ownership REST endpoint @jospoortvliet
- feat: add --to-circle flag to deck:transfer-ownership OCC command @jospoortvliet
- feat: show team icon for circle-owned boards and add Transfer ownership button in sharing sidebar @jospoortvliet
- feat: update default content @luka-nextcloud [#6740](https://github.com/nextcloud/deck/pull/6740)
- feat: add board import and export @luka-nextcloud [#6872](https://github.com/nextcloud/deck/pull/6872)
- feat: use outline icons @luka-nextcloud [#7114](https://github.com/nextcloud/deck/pull/7114)
Expand Down
38 changes: 26 additions & 12 deletions lib/Command/TransferOwnership.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/
namespace OCA\Deck\Command;

use OCA\Deck\Db\Acl;
use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Service\BoardService;
use OCA\Deck\Service\PermissionService;
Expand Down Expand Up @@ -57,15 +58,23 @@ protected function configure() {
InputOption::VALUE_NONE,
'Reassign card details of the old owner to the new one'
)
->addOption(
'to-circle',
null,
InputOption::VALUE_NONE,
'Treat <newOwner> as a circle ID instead of a user UID'
)
;
}

protected function execute(InputInterface $input, OutputInterface $output): int {
$owner = $input->getArgument('owner');
$newOwner = $input->getArgument('newOwner');
$boardId = $input->getArgument('boardId');

$remapAssignment = $input->getOption('remap');
$toCircle = $input->getOption('to-circle');
$newOwnerType = $toCircle ? Acl::PERMISSION_TYPE_CIRCLE : Acl::PERMISSION_TYPE_USER;
$newOwnerLabel = $toCircle ? "circle $newOwner" : $newOwner;

$this->boardService->setUserId($owner);
$this->permissionService->setUserId($owner);
Expand All @@ -83,26 +92,31 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}

if ($boardId) {
$output->writeln('Transfer board ' . $board->getTitle() . ' from ' . $board->getOwner() . " to $newOwner");
$output->writeln('Transfer board ' . $board->getTitle() . ' from ' . $board->getOwner() . " to $newOwnerLabel");
} else {
$output->writeln("Transfer all boards from $owner to $newOwner");
$output->writeln("Transfer all boards from $owner to $newOwnerLabel");
}

$question = new ConfirmationQuestion('Do you really want to continue? (y/n) ', false);
if (!$this->questionHelper->ask($input, $output, $question)) {
return 1;
}

if ($boardId) {
$this->boardService->transferBoardOwnership($boardId, $newOwner, $remapAssignment);
$output->writeln('<info>Board ' . $board->getTitle() . ' from ' . $board->getOwner() . " transferred to $newOwner completed</info>");
return 0;
}

foreach ($this->boardService->transferOwnership($owner, $newOwner, $remapAssignment) as $board) {
$output->writeln(' - ' . $board->getTitle() . ' transferred');
try {
if ($boardId) {
$this->boardService->transferBoardOwnership($boardId, $newOwner, $remapAssignment, $newOwnerType);
$output->writeln('<info>Board ' . $board->getTitle() . " transferred to $newOwnerLabel</info>");
return 0;
}

foreach ($this->boardService->transferOwnership($owner, $newOwner, $remapAssignment, $newOwnerType) as $board) {
$output->writeln(' - ' . $board->getTitle() . ' transferred');
}
$output->writeln("<info>All boards from $owner transferred to $newOwnerLabel</info>");
} catch (\Exception $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln("<info>All boards from $owner to $newOwner transferred</info>");

return 0;
}
Expand Down
8 changes: 5 additions & 3 deletions lib/Controller/BoardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,13 @@ public function clone(int $boardId, bool $withCards = false, bool $withAssignmen
/**
* @NoAdminRequired
*/
public function transferOwner(int $boardId, string $newOwner): DataResponse {
public function transferOwner(int $boardId, string $newOwner, int $newOwnerType = Acl::PERMISSION_TYPE_USER): DataResponse {
if ($newOwnerType !== Acl::PERMISSION_TYPE_USER && $newOwnerType !== Acl::PERMISSION_TYPE_CIRCLE) {
return new DataResponse(['message' => 'Invalid owner type'], HTTP::STATUS_BAD_REQUEST);
}
if ($this->permissionService->userIsBoardOwner($boardId, $this->userId)) {
return new DataResponse($this->boardService->transferBoardOwnership($boardId, $newOwner), HTTP::STATUS_OK);
return new DataResponse($this->boardService->transferBoardOwnership($boardId, $newOwner, false, $newOwnerType), HTTP::STATUS_OK);
}

return new DataResponse([], HTTP::STATUS_UNAUTHORIZED);
}

Expand Down
4 changes: 4 additions & 0 deletions lib/Db/Board.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
* @method void setLastModified(int $lastModified)
* @method string getOwner()
* @method void setOwner(string $owner)
* @method int getOwnerType()
* @method void setOwnerType(int $ownerType)
* @method string getColor()
* @method void setColor(string $color)
* @method void setShareToken(string $shareToken)
Expand All @@ -32,6 +34,7 @@
class Board extends RelationalEntity {
protected $title;
protected $owner;
protected $ownerType = 0;
protected $color;
protected $archived = false;
/** @var Label[]|null */
Expand All @@ -53,6 +56,7 @@ class Board extends RelationalEntity {
public function __construct() {
$this->addType('id', 'integer');
$this->addType('shared', 'integer');
$this->addType('ownerType', 'integer');
$this->addType('archived', 'boolean');
$this->addType('deletedAt', 'integer');
$this->addType('lastModified', 'integer');
Expand Down
113 changes: 101 additions & 12 deletions lib/Db/BoardMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,23 @@ public function findBoardIds(string $userId): array {
return (int)$id;
}, $result->fetchAll(\PDO::FETCH_COLUMN));
$result->closeCursor();
return array_unique(array_merge($ownerBoards, $sharedBoards));

// Owned by circles the user is in (reuse $circles already fetched above)
$circleOwnedBoards = [];
if (count($circles) !== 0) {
$qb = $this->db->getQueryBuilder();
$qb->selectDistinct('b.id')
->from($this->getTableName(), 'b')
->where($qb->expr()->eq('owner_type', $qb->createNamedParameter(Acl::PERMISSION_TYPE_CIRCLE, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->in('owner', $qb->createNamedParameter($circles, IQueryBuilder::PARAM_STR_ARRAY)));
$result = $qb->executeQuery();
$circleOwnedBoards = array_map(function (string $id) {
return (int)$id;
}, $result->fetchAll(\PDO::FETCH_COLUMN));
$result->closeCursor();
}

return array_unique(array_merge($ownerBoards, $sharedBoards, $circleOwnedBoards));
}
/**
* @param int $externalId
Expand All @@ -156,7 +172,8 @@ public function findAllForUser(string $userId, ?int $since = null, bool $include
$userBoards = $this->findAllByUser($userId, null, null, $since, $includeArchived, $before, $term);
$groupBoards = $this->findAllByGroups($userId, $groups, null, null, $since, $includeArchived, $before, $term);
$circleBoards = $this->findAllByCircles($userId, null, null, $since, $includeArchived, $before, $term);
$allBoards = array_values(array_unique(array_merge($userBoards, $groupBoards, $circleBoards)));
$circleOwnedBoards = $this->findAllByCircleOwner($userId, null, null, $since, $includeArchived, $before, $term);
$allBoards = array_values(array_unique(array_merge($userBoards, $groupBoards, $circleBoards, $circleOwnedBoards)));

// Could be moved outside
$acls = $this->aclMapper->findIn(array_map(function ($board) {
Expand Down Expand Up @@ -192,7 +209,7 @@ public function findAllByUser(string $userId, ?int $limit = null, ?int $offset =
// FIXME this used to be a UNION to get boards owned by $userId and the user shares in one single query
// Is it possible with the query builder?
$qb = $this->db->getQueryBuilder();
$qb->select('id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token')
$qb->select('id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token')
// this does not work in MySQL/PostgreSQL
//->selectAlias('0', 'shared')
->from('deck_boards', 'b')
Expand Down Expand Up @@ -232,7 +249,7 @@ public function findAllByUser(string $userId, ?int $limit = null, ?int $offset =

// shared with user
$qb = $this->db->getQueryBuilder();
$qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token')
$qb->select('b.id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token')
//->selectAlias('1', 'shared')
->from('deck_boards', 'b')
->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id'))
Expand Down Expand Up @@ -300,7 +317,7 @@ public function findAllByGroups(string $userId, array $groups, ?int $limit = nul
return [];
}
$qb = $this->db->getQueryBuilder();
$qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token')
$qb->select('b.id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token')
//->selectAlias('2', 'shared')
->from('deck_boards', 'b')
->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id'))
Expand Down Expand Up @@ -356,7 +373,7 @@ public function findAllByCircles(string $userId, ?int $limit = null, ?int $offse
}

$qb = $this->db->getQueryBuilder();
$qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token')
$qb->select('b.id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token')
//->selectAlias('2', 'shared')
->from('deck_boards', 'b')
->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id'))
Expand Down Expand Up @@ -404,9 +421,63 @@ public function findAllByCircles(string $userId, ?int $limit = null, ?int $offse
return $entries;
}

/**
* Find all boards that are owned by a circle the given user is a member of.
*
* These are boards where owner_type = PERMISSION_TYPE_CIRCLE and the owner field
* holds a circle ID that the user belongs to. They are distinct from boards that
* are merely *shared* with a circle via an ACL entry (handled by findAllByCircles).
*/
public function findAllByCircleOwner(string $userId, ?int $limit = null, ?int $offset = null, ?int $since = null,
bool $includeArchived = true, ?int $before = null, ?string $term = null): array {
$circles = $this->circlesService->getUserCircles($userId);
if (count($circles) === 0) {
return [];
}

$qb = $this->db->getQueryBuilder();
$qb->select('id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token')
->from('deck_boards')
->where($qb->expr()->eq('owner_type', $qb->createNamedParameter(Acl::PERMISSION_TYPE_CIRCLE, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->in('owner', $qb->createNamedParameter($circles, IQueryBuilder::PARAM_STR_ARRAY)));
if (!$includeArchived) {
$qb->andWhere($qb->expr()->eq('archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
->andWhere($qb->expr()->eq('deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
}
if ($since !== null) {
$qb->andWhere($qb->expr()->gt('last_modified', $qb->createNamedParameter($since, IQueryBuilder::PARAM_INT)));
}
if ($before !== null) {
$qb->andWhere($qb->expr()->lt('last_modified', $qb->createNamedParameter($before, IQueryBuilder::PARAM_INT)));
}
if ($term !== null) {
$qb->andWhere(
$qb->expr()->iLike(
'title',
$qb->createNamedParameter(
'%' . $this->db->escapeLikeParameter($term) . '%',
IQueryBuilder::PARAM_STR
)
)
);
}
$qb->orderBy('id');
if ($limit !== null) {
$qb->setMaxResults($limit);
}
if ($offset !== null) {
$qb->setFirstResult($offset);
}
$entries = $this->findEntities($qb);
foreach ($entries as $entry) {
$entry->setShared(0);
}
return $entries;
}

public function findAllByTeam(string $teamId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token')
$qb->select('b.id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token')
->from('deck_boards', 'b')
->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id'))
->where($qb->expr()->eq('acl.type', $qb->createNamedParameter(Acl::PERMISSION_TYPE_CIRCLE, IQueryBuilder::PARAM_INT)))
Expand Down Expand Up @@ -434,7 +505,7 @@ public function findTeamsForBoard(int $boardId): array {

public function isSharedWithTeam(int $boardId, string $teamId): bool {
$qb = $this->db->getQueryBuilder();
$qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token')
$qb->select('b.id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token')
->from('deck_boards', 'b')
->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id'))
->where($qb->expr()->eq('b.id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT)))
Expand All @@ -460,7 +531,7 @@ public function findToDelete() {
// add buffer of 5 min
$timeLimit = time() - (60 * 5);
$qb = $this->db->getQueryBuilder();
$qb->select('id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token')
$qb->select('id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token')
->from('deck_boards')
->where($qb->expr()->gt('deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->lt('deleted_at', $qb->createNamedParameter($timeLimit, IQueryBuilder::PARAM_INT)));
Expand Down Expand Up @@ -540,11 +611,28 @@ public function mapAcl(Acl &$acl): void {
/**
* @param Board $board
*/
public function mapOwner(Board &$board) {
public function mapOwner(Board &$board): void {
$userManager = $this->userManager;
$cloudIdManager = $this->cloudIdManager;
$circlesService = $this->circlesService;
$logger = $this->logger;
$externalId = $board->getExternalId();
$board->resolveRelation('owner', function ($owner) use (&$userManager, &$cloudIdManager, $externalId) {
$ownerType = $board->getOwnerType();
$board->resolveRelation('owner', function ($owner) use ($userManager, $cloudIdManager, $circlesService, $logger, $externalId, $ownerType) {
if ($ownerType === Acl::PERMISSION_TYPE_CIRCLE) {
if (!$circlesService->isCirclesEnabled()) {
return null;
}
try {
$circle = $circlesService->getCircle($owner);
if ($circle !== null) {
return new Circle($circle);
}
} catch (\Throwable $e) {
$logger->error('Failed to get circle details when mapping board owner', ['exception' => $e]);
}
return null;
}
if ($externalId !== null) {
$cloudId = $cloudIdManager->resolveCloudId($owner);
return new FederatedUser($cloudId);
Expand All @@ -559,10 +647,11 @@ public function mapOwner(Board &$board) {
/**
* @throws \OCP\DB\Exception
*/
public function transferOwnership(string $ownerId, string $newOwnerId, $boardId = null): void {
public function transferOwnership(string $ownerId, string $newOwnerId, ?int $boardId = null, int $newOwnerType = Acl::PERMISSION_TYPE_USER): void {
$qb = $this->db->getQueryBuilder();
$qb->update('deck_boards')
->set('owner', $qb->createNamedParameter($newOwnerId, IQueryBuilder::PARAM_STR))
->set('owner_type', $qb->createNamedParameter($newOwnerType, IQueryBuilder::PARAM_INT))
->where($qb->expr()->eq('owner', $qb->createNamedParameter($ownerId, IQueryBuilder::PARAM_STR)));
if ($boardId !== null) {
$qb->andWhere($qb->expr()->eq('id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT)));
Expand Down
41 changes: 41 additions & 0 deletions lib/Migration/Version11002Date20260429000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

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

declare(strict_types=1);
namespace OCA\Deck\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

class Version11002Date20260429000000 extends SimpleMigrationStep {
/**
* Add owner_type to deck_boards to support non-user board owners (e.g. circles/teams).
*
* Values mirror the Acl::PERMISSION_TYPE_* constants:
* 0 = user (default, preserves existing behaviour)
* 7 = circle/team
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

if ($schema->hasTable('deck_boards')) {
$table = $schema->getTable('deck_boards');
if (!$table->hasColumn('owner_type')) {
$table->addColumn('owner_type', 'smallint', [
'notnull' => true,
'default' => 0,
'unsigned' => false,
]);
}
}

return $schema;
}
}
Loading
Loading