declaration in its info.xml). We use them * directly anyway, per explicit product requirement (no HTTP/OCS calls * against Deck are allowed), but every resolution and call is guarded so a * Deck-side change degrades a single workflow instead of crashing the * whole background run. */ class DeckIntegrationService { private const DECK_APP_ID = 'deck'; /** * Deck's Acl::PERMISSION_TYPE_* values, inlined so this class keeps * working (degrading to "treat it as a user") if Deck ever moves or * renames the constants. */ private const ACL_TYPE_USER = 0; private const ACL_TYPE_GROUP = 1; public function __construct( private IAppManager $appManager, private IUserManager $userManager, private IGroupManager $groupManager, private LoggerInterface $logger, ) { } public function assertDeckAvailable(IUser $user): void { if (!$this->appManager->isEnabledForUser(self::DECK_APP_ID, $user)) { throw new DeckUnavailableException('Deck is not enabled for user ' . $user->getUID()); } if (!class_exists(CardService::class) || !class_exists(BoardService::class) || !class_exists(StackService::class)) { throw new DeckUnavailableException('Deck internal classes are not available'); } } /** * @return array * * Only ever call this from a *request*: Deck injects the current user id * into BoardService as a plain string frozen at construction time, so in * the background job (one process, many users, a container-cached * BoardService) this would answer for the wrong user — or for none at * all. The job uses findStackIds() instead, which goes through Deck's * PermissionService and therefore reads the live session. */ public function listBoardsForCurrentUser(): array { $boards = $this->call(function () { $boardService = $this->resolve(BoardService::class); try { // getUserBoards(?int $since, bool $includeArchived, …): with // the default $includeArchived = true, Deck skips the // `archived = false AND deleted_at = 0` conditions entirely, // so archived *and* trashed boards come back. Ask for the // filtered query instead of sorting them out afterwards. return $boardService->getUserBoards(null, false); } catch (Throwable $e) { // Older/newer Deck with a different signature: fall back to // the unfiltered call, isBoardHidden() below still filters. return $boardService->getUserBoards(); } }, []); // Deck merges own/group/circle boards, so the same board can come // back more than once; and boards in the trash or archived ones are // no useful automation target. Keyed by id => deduplicated. $result = []; foreach ($boards as $board) { $id = (int)$board->getId(); if (isset($result[$id]) || $this->isBoardHidden($board)) { continue; } $title = $board->getTitle(); $result[$id] = [ 'id' => $id, 'title' => (is_string($title) && $title !== '') ? $title : ('#' . $id), ]; } return array_values($result); } private function isBoardHidden(mixed $board): bool { if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) { return true; } return method_exists($board, 'getArchived') && $board->getArchived() === true; } /** * @return array */ public function listStacks(int $boardId): array { $stacks = $this->call(function () use ($boardId) { return $this->resolve(StackService::class)->findAll($boardId); }, []); return array_map( static fn ($stack) => ['id' => (int)$stack->getId(), 'title' => $stack->getTitle()], $stacks, ); } /** * Ids of the board's stacks, for deciding whether a stored workflow still * points at anything real. * * Returns `null` when the board itself is gone or not readable for the * current user — `StackService::findAll()` runs a Deck permission check * first, and that one reads the *live* session, which is what makes this * usable from the impersonating background job. Any other failure throws, * because "Deck is broken right now" must never be mistaken for "the user * deleted this board". * * @return int[]|null * @throws DeckUnavailableException */ public function findStackIds(int $boardId): ?array { try { $stacks = $this->resolve(StackService::class)->findAll($boardId); } catch (DeckUnavailableException $e) { throw $e; } catch (Throwable $e) { if ($this->isMissingOrForbidden($e)) { return null; } $this->logger->error('Could not list stacks of board ' . $boardId . ': ' . $e->getMessage(), [ 'app' => 'workflow_deck_automation', 'exception' => $e, ]); throw new DeckUnavailableException('Deck call failed: ' . $e->getMessage(), 0, $e); } return array_map(static fn ($stack) => (int)$stack->getId(), $stacks); } /** * Deck's own exception classes are referenced by name: `is_a()` with a * string simply returns false when the class does not exist, so a missing * or renamed Deck degrades to "unknown error" instead of fataling. */ private function isMissingOrForbidden(Throwable $e): bool { if ($e instanceof DoesNotExistException) { return true; } foreach (['OCA\Deck\NoPermissionException', 'OCA\Deck\NotFoundException'] as $class) { if (is_a($e, $class)) { return true; } } return false; } /** * @return array */ public function listLabels(int $boardId): array { $labels = $this->call(function () use ($boardId) { $board = $this->resolve(BoardService::class)->find($boardId, true); return $board->getLabels() ?? []; }, []); return array_map( static fn ($label) => ['id' => $label->getId(), 'title' => $label->getTitle(), 'color' => $label->getColor()], $labels, ); } /** * Everyone who can hold a card on this board: the board owner (who is * *not* part of the ACL — a private board has an empty ACL) plus every * ACL entry, with group shares expanded to their members. * * @return array */ public function listParticipants(int $boardId): array { $uids = $this->call(function () use ($boardId) { $board = $this->resolve(BoardService::class)->find($boardId, true); $uids = []; $owner = $this->unwrapUid($board->getOwner()); if ($owner !== null) { $uids[] = $owner; } foreach ($board->getAcl() ?? [] as $entry) { $principal = $this->extractParticipantUid($entry); if ($principal === null) { continue; } $type = method_exists($entry, 'getType') ? (int)$entry->getType() : self::ACL_TYPE_USER; if ($type === self::ACL_TYPE_GROUP) { array_push($uids, ...$this->groupMemberUids($principal)); } elseif ($type === self::ACL_TYPE_USER) { $uids[] = $principal; } // Circles and federated shares are skipped: their members // cannot be resolved to plain uids here. } return $uids; }, []); $participants = []; foreach ($uids as $uid) { if (isset($participants[$uid])) { continue; } $participants[$uid] = [ 'uid' => $uid, 'displayName' => $this->userManager->get($uid)?->getDisplayName() ?? $uid, ]; } return array_values($participants); } /** * @return string[] */ private function groupMemberUids(string $groupId): array { $group = $this->groupManager->get($groupId); if ($group === null) { return []; } return array_map(static fn (IUser $user) => $user->getUID(), $group->getUsers()); } /** * Cards in the given stack that are neither archived, deleted nor marked * done, enriched so getAssignedUsers()/getLabels() are populated. * * The `deletedAt` check is not redundant: Deck's * `CardMapper::findAllByStack()` filters on `stack_id` and * `archived = false` only, so cards sitting in the trash come back too — * they are kept until Deck's own DeleteCron purges them. Without this we * would move cards the user already deleted. * * @return Card[] */ public function getActiveCardsInStack(int $stackId): array { return $this->call(function () use ($stackId) { $cardMapper = $this->resolve(CardMapper::class); $cardService = $this->resolve(CardService::class); $cards = $cardMapper->findAllByStack($stackId); $cards = $cardService->enrichCards($cards); return array_values(array_filter($cards, static function (Card $card) { if (method_exists($card, 'getDeletedAt') && (int)$card->getDeletedAt() > 0) { return false; } return !$card->getArchived() && $card->getDone() === null; })); }, []); } /** * @return string[] */ public function getCardAssignedUserIds(Card $card): array { $assigned = $card->getAssignedUsers() ?? []; $uids = []; foreach ($assigned as $entry) { $uid = $this->extractParticipantUid($entry); if ($uid !== null) { $uids[] = $uid; } } return $uids; } /** * @return int[] */ public function getCardLabelIds(Card $card): array { $labels = $card->getLabels() ?? []; $ids = []; foreach ($labels as $label) { if (method_exists($label, 'getId')) { $ids[] = (int)$label->getId(); } } return $ids; } /** * Moves a card to another stack using Deck's own reorder logic * (the exact same code path behind Deck's "move card" action). */ public function moveCard(int $cardId, int $targetStackId): void { $this->call(function () use ($cardId, $targetStackId) { $this->resolve(CardService::class)->reorder($cardId, $targetStackId, 0); return null; }, null, true); } /** * @template T * @param callable(): T $callback * @param T $fallback * @return T */ private function call(callable $callback, mixed $fallback, bool $rethrow = false) { try { return $callback(); } catch (DeckUnavailableException $e) { throw $e; } catch (Throwable $e) { $this->logger->error('Deck integration call failed: ' . $e->getMessage(), [ 'app' => 'workflow_deck_automation', 'exception' => $e, ]); if ($rethrow) { throw new DeckUnavailableException('Deck call failed: ' . $e->getMessage(), 0, $e); } return $fallback; } } /** * @template T of object * @param class-string $class * @return T */ private function resolve(string $class) { if (!class_exists($class)) { throw new DeckUnavailableException("Deck class {$class} does not exist"); } return Server::get($class); } /** * Deck's assignment/ACL entries are internal, undocumented value * objects whose exact accessor shape has changed across versions, so * we probe the common accessor names defensively instead of relying * on one fixed method signature. */ private function extractParticipantUid(mixed $entry): ?string { $uid = $this->unwrapUid($entry); if ($uid !== null) { return $uid; } if (!is_object($entry)) { return null; } foreach (['getParticipant', 'getUid', 'getParticipantUid', 'getUserId'] as $method) { if (method_exists($entry, $method)) { $uid = $this->unwrapUid($entry->$method()); if ($uid !== null) { return $uid; } } } return null; } /** * Turns whatever Deck hands out for a "participant"/"owner" field into * a plain uid. * * Enriched Deck entities do *not* return the raw uid string: their * RelationalEntity base swaps resolved relations for a * OCA\Deck\Db\RelationalObject wrapping a User/Group object, and its * primary key is the uid we want. Unenriched entities still return the * bare string, so both shapes are handled. */ private function unwrapUid(mixed $value): ?string { if (is_string($value)) { return $value !== '' ? $value : null; } if (!is_object($value)) { return null; } foreach (['getPrimaryKey', 'getUID', 'getUid', 'getId'] as $method) { if (method_exists($value, $method)) { $inner = $value->$method(); if (is_string($inner) && $inner !== '') { return $inner; } } } if (method_exists($value, 'getObject')) { $inner = $value->getObject(); foreach (['getUID', 'getUid', 'getId'] as $method) { if (is_object($inner) && method_exists($inner, $method)) { $uid = $inner->$method(); if (is_string($uid) && $uid !== '') { return $uid; } } } } return null; } }