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'; public function __construct( private IAppManager $appManager, 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 */ public function listBoardsForCurrentUser(): array { $boards = $this->call(function () { return $this->resolve(BoardService::class)->getUserBoards(); }, []); return array_map( static fn ($board) => ['id' => $board->getId(), 'title' => $board->getTitle()], $boards, ); } /** * @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' => $stack->getId(), 'title' => $stack->getTitle()], $stacks, ); } /** * @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, ); } /** * @return array */ public function listParticipants(int $boardId): array { $acl = $this->call(function () use ($boardId) { $board = $this->resolve(BoardService::class)->find($boardId, true); return $board->getAcl() ?? []; }, []); $participants = []; foreach ($acl as $entry) { $uid = $this->extractParticipantUid($entry); if ($uid !== null && !isset($participants[$uid])) { $participants[$uid] = ['uid' => $uid, 'displayName' => $uid]; } } return array_values($participants); } /** * Cards in the given stack that are neither archived nor marked done, * enriched so getAssignedUsers()/getLabels() are populated. * * @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) { 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 { if (is_string($entry)) { return $entry; } if (!is_object($entry)) { return null; } foreach (['getParticipant', 'getUid', 'getParticipantUid', 'getUserId'] as $method) { if (method_exists($entry, $method)) { $value = $entry->$method(); if (is_string($value) && $value !== '') { return $value; } if (is_object($value) && method_exists($value, 'getUID')) { return $value->getUID(); } } } return null; } }