Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 3 additions & 1 deletion appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
// boards
['name' => 'board#index', 'url' => '/boards', 'verb' => 'GET'],
['name' => 'board#create', 'url' => '/boards', 'verb' => 'POST'],
['name' => 'board#createForTeam', 'url' => '/boards/team', 'verb' => 'POST'],
['name' => 'board#read', 'url' => '/boards/{boardId}', 'verb' => 'GET'],
['name' => 'board#update', 'url' => '/boards/{boardId}', 'verb' => 'PUT'],
['name' => 'board#delete', 'url' => '/boards/{boardId}', 'verb' => 'DELETE'],
Expand Down Expand Up @@ -78,8 +79,9 @@

// api
['name' => 'board_api#index', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'GET'],
['name' => 'board_api#get', 'url' => '/api/v{apiVersion}/boards/{boardId}', 'verb' => 'GET'],
['name' => 'board_api#create', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'POST'],
['name' => 'board_api#createForTeam', 'url' => '/api/v{apiVersion}/boards/team', 'verb' => 'POST'],
['name' => 'board_api#get', 'url' => '/api/v{apiVersion}/boards/{boardId}', 'verb' => 'GET'],
['name' => 'board_api#delete', 'url' => '/api/v{apiVersion}/boards/{boardId}', 'verb' => 'DELETE'],
['name' => 'board_api#update', 'url' => '/api/v{apiVersion}/boards/{boardId}', 'verb' => 'PUT'],
['name' => 'board_api#undo_delete', 'url' => '/api/v{apiVersion}/boards/{boardId}/undo_delete', 'verb' => 'POST'],
Expand Down
2 changes: 2 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

use Exception;
use OCA\Circles\Events\CircleDestroyedEvent;
use OCA\Circles\Events\RemovingCircleMemberEvent;
use OCA\Deck\Capabilities;
use OCA\Deck\Collaboration\Resources\ResourceProvider;
use OCA\Deck\Collaboration\Resources\ResourceProviderCard;
Expand Down Expand Up @@ -170,6 +171,7 @@ public function register(IRegistrationContext $context): void {

$context->registerEventListener(UserDeletedEvent::class, ParticipantCleanupListener::class);
$context->registerEventListener(GroupDeletedEvent::class, ParticipantCleanupListener::class);
$context->registerEventListener(RemovingCircleMemberEvent::class, ParticipantCleanupListener::class);
$context->registerEventListener(CircleDestroyedEvent::class, ParticipantCleanupListener::class);

// Event listening for realtime updates via notify_push
Expand Down
11 changes: 11 additions & 0 deletions lib/Controller/BoardApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@ public function create(string $title, string $color): DataResponse {
return new DataResponse($board, HTTP::STATUS_OK);
}

/**
* Create a board attached to a team (circle).
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[CORS]
public function createForTeam(string $title, string $teamId, ?string $color = null): DataResponse {
Comment thread
grnd-alt marked this conversation as resolved.
Outdated
$board = $this->boardService->createForTeam($title, $this->userId, $color, $teamId);
return new DataResponse($board, HTTP::STATUS_OK);
}

/**
* Update a board with the specified boardId, title and color, and archived state.
*/
Expand Down
5 changes: 5 additions & 0 deletions lib/Controller/BoardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ public function create(string $title, string $color): Board {
return $this->boardService->create($title, $this->userId, $color);
}

#[NoAdminRequired]
public function createForTeam(string $title, string $teamId, ?string $color = null): Board {
Comment thread
grnd-alt marked this conversation as resolved.
Outdated
return $this->boardService->createForTeam($title, $this->userId, $color, $teamId);
}

#[NoAdminRequired]
public function update(int $id, string $title, string $color, bool $archived): Board {
return $this->boardService->update($id, $title, $color, $archived);
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 string|null getTeamId()
* @method void setTeamId(?string $teamId)
* @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 $teamId = null;
protected $color;
protected $archived = false;
/** @var Label[]|null */
Expand All @@ -58,6 +61,7 @@ public function __construct() {
$this->addType('lastModified', 'integer');
$this->addType('shareToken', 'string');
$this->addType('externalId', 'integer');
$this->addType('teamId', 'string');
$this->addRelation('labels');
$this->addRelation('acl');
$this->addRelation('shared');
Expand Down
14 changes: 14 additions & 0 deletions lib/Db/BoardMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,20 @@ public function findAllByOwner(string $userId, ?int $limit = null, ?int $offset
return $this->findEntities($qb);
}

/**
* Find all board with the team_id set to the given teamId
*
* @return Board[]
*/
public function findAllAttachedToTeam(string $teamId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from('deck_boards')
->where($qb->expr()->eq('team_id', $qb->createNamedParameter($teamId, IQueryBuilder::PARAM_STR)))
->orderBy('id');
return $this->findEntities($qb);
}

/**
* Find all boards for a given user
*/
Expand Down
40 changes: 29 additions & 11 deletions lib/Listeners/ParticipantCleanupListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,43 +8,61 @@
namespace OCA\Deck\Listeners;

use OCA\Circles\Events\CircleDestroyedEvent;
use OCA\Circles\Events\RemovingCircleMemberEvent;
use OCA\Circles\Model\Member;
use OCA\Deck\Db\Acl;
use OCA\Deck\Db\AclMapper;
use OCA\Deck\Db\AssignmentMapper;
use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Service\TeamBoardService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Group\Events\GroupDeletedEvent;
use OCP\User\Events\UserDeletedEvent;

/** @template-implements IEventListener<Event|UserDeletedEvent|GroupDeletedEvent|CircleDestroyedEvent> */
/** @template-implements IEventListener<Event|UserDeletedEvent|GroupDeletedEvent|CircleDestroyedEvent|RemovingCircleMemberEvent> */
class ParticipantCleanupListener implements IEventListener {
private AclMapper $aclMapper;
private AssignmentMapper $assignmentMapper;
private BoardMapper $boardMapper;

public function __construct(AclMapper $aclMapper, AssignmentMapper $assignmentMapper, BoardMapper $boardMapper) {
$this->aclMapper = $aclMapper;
$this->assignmentMapper = $assignmentMapper;
$this->boardMapper = $boardMapper;
public function __construct(
private AclMapper $aclMapper,
private AssignmentMapper $assignmentMapper,
private BoardMapper $boardMapper,
private TeamBoardService $teamBoardService,
) {
}

public function handle(Event $event): void {
if ($event instanceof UserDeletedEvent) {
$boards = $this->boardMapper->findAllByOwner($event->getUser()->getUID());
$userId = $event->getUser()->getUID();
$transferredBoardIds = $this->teamBoardService->transferTeamBoardsFromDeletedUser($userId);

$boards = $this->boardMapper->findAllByOwner($userId);
foreach ($boards as $board) {
if (in_array($board->getId(), $transferredBoardIds, true)) {
Comment thread
grnd-alt marked this conversation as resolved.
Outdated
continue;
}
$this->boardMapper->delete($board);
}

$this->cleanupByParticipant(Acl::PERMISSION_TYPE_USER, $event->getUser()->getUID());
$this->cleanupByParticipant(Acl::PERMISSION_TYPE_USER, $userId);
}

if ($event instanceof GroupDeletedEvent) {
$this->cleanupByParticipant(Acl::PERMISSION_TYPE_GROUP, $event->getGroup()->getGID());
}

if ($event instanceof RemovingCircleMemberEvent) {
$member = $event->getMember();
if ($member->getUserType() === Member::TYPE_USER) {
$this->teamBoardService->handleMemberLeftTeam(
$event->getCircle()->getSingleId(),
$member->getUserId()
);
}
}

if ($event instanceof CircleDestroyedEvent) {
$circleId = $event->getCircle()->getSingleId();
$this->teamBoardService->deleteBoardsAttachedToTeam($circleId);
$this->cleanupByParticipant(Acl::PERMISSION_TYPE_CIRCLE, $circleId);
}
}
Expand Down
41 changes: 41 additions & 0 deletions lib/Migration/Version11002Date20260812120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

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

namespace OCA\Deck\Migration;

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

class Version11002Date20260812120000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

if (!$schema->hasTable('deck_boards')) {
return null;
}

$table = $schema->getTable('deck_boards');
if (!$table->hasColumn('team_id')) {
$table->addColumn('team_id', 'string', [
'notnull' => false,
'length' => 64,
'default' => null,
]);
}

if (!$table->hasIndex('deck_boards_team_id')) {
$table->addIndex(['team_id'], 'deck_boards_team_id');
}

return $schema;
}
}
51 changes: 51 additions & 0 deletions lib/Service/BoardService.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public function __construct(
private IUserManager $userManager,
private ISecureRandom $random,
private ConfigService $configService,
private CirclesService $circlesService,
private ?string $userId,
) {
}
Expand Down Expand Up @@ -228,6 +229,56 @@ public function create(string $title, string $userId, string $color): Board {
return $board;
}

/**
* Create a board with the current user as owner and attach it to a team
*
* @throws BadRequestException
* @throws NoPermissionException
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function createForTeam(string $title, string $userId, ?string $color, string $teamId): Board {
if ($color === null || $color === '') {
$color = sprintf('%06x', random_int(0, 0xffffff));
} elseif (str_starts_with($color, '#')) {
$color = substr($color, 1);
}

$this->boardServiceValidator->check(compact('title', 'userId', 'color'));

if ($teamId === '') {
throw new BadRequestException('teamId must not be empty');
}

if (!$this->circlesService->isCirclesEnabled()) {
throw new BadRequestException('Circles/Teams app is not enabled');
}

if ($this->circlesService->getCircle($teamId) === null) {
throw new BadRequestException('Team not found');
}

if (!$this->circlesService->isUserInCircle($teamId, $userId)) {
throw new NoPermissionException('You must be a member of the team to create a team board');
}

$board = $this->create($title, $userId, $color);
$board->setTeamId($teamId);
$board = $this->boardMapper->update($board);

// give edit access to the team members
$this->addAcl(
$board->getId(),
Acl::PERMISSION_TYPE_CIRCLE,
$teamId,
true,
false,
false,
);

return $this->find($board->getId());
}

/**
* @throws DoesNotExistException
* @throws NoPermissionException
Expand Down
42 changes: 42 additions & 0 deletions lib/Service/CirclesService.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,46 @@ public function getUserCircles(string $userId): array {
}
return [];
}

/**
* Replace with the next team member to own the board, with higher circles level
*/
public function findNextMemberUserId(string $circleId, ?string $excludeUserId = null): ?string {
if (!$this->circlesEnabled) {
return null;
}

try {
$circlesManager = Server::get(CirclesManager::class);
$circlesManager->startSuperSession();
$circle = $circlesManager->getCircle($circleId);
$circleMembers = [];
foreach ($circle->getMembers() as $member) {
Comment thread
grnd-alt marked this conversation as resolved.
if ($member->getUserType() !== Member::TYPE_USER) {
continue;
}
if ($member->getLevel() < Member::LEVEL_MEMBER) {
continue;
}
if ($excludeUserId !== null && $member->getUserId() === $excludeUserId) {
continue;
}
$circleMembers[] = $member;
}

if ($circleMembers === []) {
return null;
}

usort(
$circleMembers,
static fn (Member $a, Member $b): int => $b->getLevel() <=> $a->getLevel()
);

return $circleMembers[0]->getUserId();
} catch (Throwable $e) {
}

return null;
}
}
Loading
Loading