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; /** * Deck's Acl::PERMISSION_READ, inlined for the same reason. It is the key * under which PermissionService::getPermissions() reports read access. */ private const ACL_PERMISSION_READ = 0; /** * The property through which Deck carries "the current user" — a plain * string frozen at construction rather than a session lookup. See * beginUserContext(). */ private const IDENTITY_PROPERTY = 'userId'; /** * Further Deck classes holding that same frozen uid. Unlike * PermissionService these do not decide access, only whose name ends up * on an activity entry or whose unread-comment counts are computed, so * failing to pin them is logged and shrugged off. */ private const OPTIONAL_IDENTITY_CLASSES = [ CardService::class, BoardService::class, 'OCA\Deck\Activity\ActivityManager', ]; /** * @var list */ private array $identityRestore = []; public function __construct( private IAppManager $appManager, private IUserManager $userManager, private IGroupManager $groupManager, private LoggerInterface $logger, ) { } /** * Pins Deck to $user until endUserContext() — mandatory before any Deck * call made on behalf of someone who is not the logged-in web user. * * IUserSession::setUser() alone is not enough, and that is not obvious: * Deck's PermissionService — the class behind *every* permission check, * including the ones inside CardService::reorder() — receives the current * user as `private ?string $userId`. The app container fills that from * `ISession::get('user_id')` through a **shared** service, so Pimple * computes it once and caches it for the rest of the process, and * ServerContainer keeps the app container just as long. Whoever was * active when Deck's container first built PermissionService is therefore * the user every later check is evaluated against. * * In the background job that is never the user we are impersonating: it * is `null` whenever a Deck-owned job ran earlier in the same cron pass * (cron.php works through many jobs in one process), and otherwise the * first workflow owner we happened to touch. `null` fails every check, * which is how intact boards started reporting NoPermissionException and * got their workflows switched off. * * Deck exposes no API for this, so the frozen value is swapped directly * and put back in endUserContext(). PermissionService is required: if it * cannot be pinned this throws, and the caller has to skip the user * rather than run them under someone else's permissions. * * @throws DeckUnavailableException */ public function beginUserContext(IUser $user): void { if ($this->identityRestore !== []) { throw new DeckUnavailableException('A Deck user context is already active'); } $uid = $user->getUID(); try { $this->pinDeckIdentity($this->resolve(PermissionService::class), $uid); } catch (Throwable $e) { $this->endUserContext(); throw new DeckUnavailableException( 'Could not run Deck as ' . $uid . ': ' . $e->getMessage(), 0, $e, ); } foreach (self::OPTIONAL_IDENTITY_CLASSES as $class) { try { $this->pinDeckIdentity($this->resolve($class), $uid); } catch (Throwable $e) { $this->logger->debug('Could not pin ' . $class . ' to ' . $uid . ': ' . $e->getMessage(), [ 'app' => 'workflow_deck_automation', ]); } } } /** * Restores what beginUserContext() replaced. Idempotent, so it can be * called unconditionally from a finally block. */ public function endUserContext(): void { foreach (array_reverse($this->identityRestore) as [$property, $service, $previous]) { try { $property->setValue($service, $previous); } catch (Throwable $e) { $this->logger->warning('Could not restore Deck identity on ' . $service::class . ': ' . $e->getMessage(), [ 'app' => 'workflow_deck_automation', ]); } } $this->identityRestore = []; } private function pinDeckIdentity(object $service, string $uid): void { $property = new ReflectionProperty($service, self::IDENTITY_PROPERTY); $previous = $property->isInitialized($service) ? $property->getValue($service) : null; $property->setValue($service, $uid); $this->identityRestore[] = [$property, $service, is_string($previous) ? $previous : null]; } 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*: getUserBoards() reads the uid * BoardService froze at construction time, and beginUserContext() cannot * help here — it repoints that field, but Deck may already have cached * board lists behind it, and the job has no use for a board *list* * anyway. It asks findStackIds() about one known board id instead. */ 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` only on positive knowledge that the board is gone, in the * trash, or no longer readable for `$userId`. Every other failure throws, * because "Deck is broken right now" must never be mistaken for "the user * deleted this board" — the caller reacts by switching the workflow off * and mailing its owner. * * Deliberately assembled from pieces that cannot answer for the wrong * user. BoardMapper and StackMapper are plain QBMappers with no user * state (StackMapper::findAll() filters `deleted_at = 0` itself), and * getPermissions() is handed the uid explicitly instead of falling back * to PermissionService's construction-time `$userId`. Leaning on that * frozen value — which is exactly what `StackService::findAll()` does * internally — is what disabled workflows whose board was perfectly * intact, so this must not be "simplified" back to one service call even * once beginUserContext() has pinned the identity. * * @return int[]|null * @throws DeckUnavailableException */ public function findStackIds(int $boardId, string $userId): ?array { try { $board = $this->resolve(BoardMapper::class)->find($boardId); if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) { return null; } $permissions = $this->resolve(PermissionService::class)->getPermissions($boardId, $userId); if (!array_key_exists(self::ACL_PERMISSION_READ, $permissions)) { // Unknown shape: say nothing rather than something wrong. throw new DeckUnavailableException('Deck reported no read permission for board ' . $boardId); } if ($permissions[self::ACL_PERMISSION_READ] !== true) { return null; } $stacks = $this->resolve(StackMapper::class)->findAll($boardId); } catch (DeckUnavailableException $e) { throw $e; } catch (Throwable $e) { if ($this->isMissing($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. * * `OCA\Deck\NoPermissionException` is deliberately *not* treated as "the * board is gone" here. It used to be, and that is what switched intact * workflows off: Deck raises it for any check that fails, including every * check evaluated against the wrong frozen uid — which says nothing at * all about whether the board still exists. */ private function isMissing(Throwable $e): bool { if ($e instanceof DoesNotExistException) { return true; } return is_a($e, 'OCA\Deck\NotFoundException'); } /** * @return array */ public function listLabels(int $boardId): array { $labels = $this->call(fn () => $this->readLabels($boardId), []); return array_map( static fn ($label) => ['id' => (int)$label->getId(), 'title' => $label->getTitle(), 'color' => $label->getColor()], $labels, ); } /** * What a workflow's filters *could* legitimately refer to on this board. * * Unlike listLabels()/listParticipants() this throws instead of degrading * to an empty list, because its only caller draws a conclusion from * emptiness ("this filter cannot match anything any more"). Same rule as * findStackIds(): a Deck hiccup must never be read as positive knowledge. * * @return array{labelIds: int[], participantUids: string[]} * @throws DeckUnavailableException */ public function findFilterOptions(int $boardId): array { try { $board = $this->readBoard($boardId); return [ 'labelIds' => array_map( static fn ($label) => (int)$label->getId(), $board->getLabels() ?? [], ), 'participantUids' => array_values(array_unique($this->collectParticipantUids($board))), ]; } catch (DeckUnavailableException $e) { throw $e; } catch (Throwable $e) { $this->logger->error('Could not read filter options of board ' . $boardId . ': ' . $e->getMessage(), [ 'app' => 'workflow_deck_automation', 'exception' => $e, ]); throw new DeckUnavailableException('Deck call failed: ' . $e->getMessage(), 0, $e); } } /** * Titles of a workflow's board and stacks, for the notification mail. * * Best effort by design: this only decorates a mail that is being sent * because a card *was* already moved, so anything unreadable degrades to * `#` rather than costing the user their notification. * * @return array{board: string, sourceStack: string, targetStack: string} */ public function describeTargets(int $boardId, int $sourceStackId, int $targetStackId): array { $titles = $this->call(function () use ($boardId) { $stackTitles = []; foreach ($this->resolve(StackMapper::class)->findAll($boardId) as $stack) { $stackTitles[(int)$stack->getId()] = (string)$stack->getTitle(); } return [ 'board' => (string)$this->resolve(BoardMapper::class)->find($boardId)->getTitle(), 'stacks' => $stackTitles, ]; }, ['board' => '', 'stacks' => []]); return [ 'board' => self::titleOr($titles['board'], $boardId), 'sourceStack' => self::titleOr($titles['stacks'][$sourceStackId] ?? '', $sourceStackId), 'targetStack' => self::titleOr($titles['stacks'][$targetStackId] ?? '', $targetStackId), ]; } private static function titleOr(string $title, int $id): string { return $title !== '' ? $title : ('#' . $id); } /** * 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(fn () => $this->collectParticipantUids($this->readBoard($boardId)), []); $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); } /** * The enriched board — `$fullDetails = true` is what populates labels and * the ACL. Shared by every caller that needs one so a single Deck read * serves them all. */ private function readBoard(int $boardId): mixed { return $this->resolve(BoardService::class)->find($boardId, true); } private function readLabels(int $boardId): array { return $this->readBoard($boardId)->getLabels() ?? []; } /** * @return string[] may contain duplicates */ private function collectParticipantUids(mixed $board): array { $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; } /** * @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); // enrichCards() mutates each $cards entry in place (labels, assigned users, ...) but // *returns* CardDetails wrappers whose own fields (duedate, stackId, ...) were never // copied from the wrapped card - only its overridden jsonSerialize() reads through to // them. Capturing that return value here made every card's getDuedate()/getDaysUntilDue() // resolve to null, so isOverdue() was always false and no workflow ever matched a card. // Keep using the original, now-enriched $cards instead. $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; } }