Skip to content
Open
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
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 @@ -168,9 +169,10 @@
$context->registerEventListener(AclCreatedEvent::class, ResourceListener::class);
$context->registerEventListener(AclDeletedEvent::class, ResourceListener::class);

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

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

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

InvalidArgument

lib/AppInfo/Application.php:172:60: InvalidArgument: Argument 2 of OCP\AppFramework\Bootstrap\IRegistrationContext::registerEventListener expects class-string<OCP\EventDispatcher\IEventListener<OCP\EventDispatcher\Event>>, but OCA\Deck\Listeners\ParticipantCleanupListener::class provided (see https://psalm.dev/004)
$context->registerEventListener(GroupDeletedEvent::class, ParticipantCleanupListener::class);

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

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

InvalidArgument

lib/AppInfo/Application.php:173:61: InvalidArgument: Argument 2 of OCP\AppFramework\Bootstrap\IRegistrationContext::registerEventListener expects class-string<OCP\EventDispatcher\IEventListener<OCP\EventDispatcher\Event>>, but OCA\Deck\Listeners\ParticipantCleanupListener::class provided (see https://psalm.dev/004)
$context->registerEventListener(RemovingCircleMemberEvent::class, ParticipantCleanupListener::class);

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

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

InvalidArgument

lib/AppInfo/Application.php:174:69: InvalidArgument: Argument 2 of OCP\AppFramework\Bootstrap\IRegistrationContext::registerEventListener expects class-string<OCP\EventDispatcher\IEventListener<OCP\EventDispatcher\Event>>, but OCA\Deck\Listeners\ParticipantCleanupListener::class provided (see https://psalm.dev/004)

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

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedClass

lib/AppInfo/Application.php:174:35: UndefinedClass: Class, interface or enum named OCA\Circles\Events\RemovingCircleMemberEvent does not exist (see https://psalm.dev/019)
$context->registerEventListener(CircleDestroyedEvent::class, ParticipantCleanupListener::class);

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

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

InvalidArgument

lib/AppInfo/Application.php:175:64: InvalidArgument: Argument 2 of OCP\AppFramework\Bootstrap\IRegistrationContext::registerEventListener expects class-string<OCP\EventDispatcher\IEventListener<OCP\EventDispatcher\Event>>, but OCA\Deck\Listeners\ParticipantCleanupListener::class provided (see https://psalm.dev/004)

// Event listening for realtime updates via notify_push
$context->registerEventListener(SessionCreatedEvent::class, LiveUpdateListener::class);
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 {
$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 {
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 {

Check failure on line 24 in lib/Listeners/ParticipantCleanupListener.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

InvalidTemplateParam

lib/Listeners/ParticipantCleanupListener.php:24:45: InvalidTemplateParam: Extended template param T expects type OCP\EventDispatcher\Event, type OCA\Circles\Events\CircleDestroyedEvent|OCA\Circles\Events\RemovingCircleMemberEvent|OCP\EventDispatcher\Event|OCP\Group\Events\GroupDeletedEvent|OCP\User\Events\UserDeletedEvent given (see https://psalm.dev/183)

Check failure on line 24 in lib/Listeners/ParticipantCleanupListener.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedDocblockClass

lib/Listeners/ParticipantCleanupListener.php:24:7: UndefinedDocblockClass: Docblock-defined class, interface or enum named OCA\Circles\Events\RemovingCircleMemberEvent does not exist (see https://psalm.dev/200)
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 {

Check failure on line 33 in lib/Listeners/ParticipantCleanupListener.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

MoreSpecificImplementedParamType

lib/Listeners/ParticipantCleanupListener.php:33:31: MoreSpecificImplementedParamType: Argument 1 of OCA\Deck\Listeners\ParticipantCleanupListener::handle has the more specific type 'OCP\EventDispatcher\Event', expecting 'OCA\Circles\Events\RemovingCircleMemberEvent|OCP\EventDispatcher\Event' as defined by OCP\EventDispatcher\IEventListener::handle (see https://psalm.dev/140)
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)) {
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) {

Check failure on line 53 in lib/Listeners/ParticipantCleanupListener.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedClass

lib/Listeners/ParticipantCleanupListener.php:53:25: UndefinedClass: Class, interface or enum named OCA\Circles\Events\RemovingCircleMemberEvent does not exist (see https://psalm.dev/019)
$member = $event->getMember();
if ($member !== null && $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 @@
}
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) {

Check failure on line 122 in lib/Service/CirclesService.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedMethod

lib/Service/CirclesService.php:122:22: UndefinedMethod: Method OCA\Circles\Model\Circle::getMembers does not exist (see https://psalm.dev/022)
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